fix(client): fix ESLint errors — real latent bugs (R2a)
ESLint surfaced 6 errors, incl. real bugs invisible before: - ImportTransactionCsvSection called importErrorState() without importing it — its own error handler would throw ReferenceError on a failed CSV import. - client/components/MobileTrackerRow.jsx was a dead duplicate (unused; the live one is tracker/MobileTrackerRow.jsx) with undefined-setter refs → deleted. - StatusPage dbOk had a dead '?? true' (constant boolean LHS) — restored the intended default-true-when-unknown. - MobileBillRow redundant !! in a ternary condition. Lint is now 0 errors; wired 'npm run lint' into 'npm run ci'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c679022592
commit
5e267e4fa7
|
|
@ -24,7 +24,7 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
const autopayClass = useMemo(() => {
|
||||
return cn(
|
||||
'rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300',
|
||||
!!bill.autopay_enabled ? 'opacity-100' : 'opacity-0',
|
||||
bill.autopay_enabled ? 'opacity-100' : 'opacity-0',
|
||||
);
|
||||
}, [bill.autopay_enabled]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, Pencil, Settings2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn, fmt, fmtDate, localDateString } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { api } from '@/api.js';
|
||||
|
||||
const MONTHS = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
|
||||
const ROW_STATUS_CLS = {
|
||||
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
|
||||
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
|
||||
upcoming: '',
|
||||
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
|
||||
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
|
||||
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
|
||||
};
|
||||
|
||||
function paymentDateForTrackerMonth(year, month, dueDay) {
|
||||
const now = new Date();
|
||||
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||||
return localDateString();
|
||||
}
|
||||
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const day = Number.isInteger(Number(dueDay))
|
||||
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
|
||||
: 1;
|
||||
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const displayVal = useMemo(() => {
|
||||
if (field === 'amount') {
|
||||
return row.total_paid > 0 ? fmt(row.total_paid) : '—';
|
||||
}
|
||||
return row.last_paid_date ? fmtDate(row.last_paid_date) : '—';
|
||||
}, [field, row]);
|
||||
|
||||
const isEmpty = useMemo(() => {
|
||||
if (field === 'amount') return row.total_paid <= 0;
|
||||
return !row.last_paid_date;
|
||||
}, [field, row]);
|
||||
|
||||
const mismatch = useMemo(() => {
|
||||
if (field === 'amount') {
|
||||
return row.total_paid > 0 && row.total_paid !== threshold;
|
||||
}
|
||||
return false;
|
||||
}, [field, row, threshold]);
|
||||
|
||||
function startEdit() {
|
||||
if (editing) return;
|
||||
setValue(field === 'amount'
|
||||
? (row.total_paid > 0 ? String(row.total_paid) : '')
|
||||
: (row.last_paid_date || ''));
|
||||
setEditing(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
setEditing(false);
|
||||
const val = value.trim();
|
||||
if (!val) return;
|
||||
try {
|
||||
if (row.payments && row.payments.length > 0) {
|
||||
const update = {};
|
||||
if (field === 'amount') update.amount = parseFloat(val);
|
||||
if (field === 'date') update.paid_date = val;
|
||||
await api.updatePayment(row.payments[0].id, update);
|
||||
} else {
|
||||
await api.createPayment({
|
||||
bill_id: row.id,
|
||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||
});
|
||||
}
|
||||
toast.success('Saved');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter') inputRef.current?.blur();
|
||||
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={field === 'date' ? 'date' : 'number'}
|
||||
step={field === 'amount' ? '0.01' : undefined}
|
||||
min={field === 'amount' ? '0' : undefined}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
className="tracker-number h-7 w-28 text-right text-sm font-medium bg-background/80 border-border/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEdit}
|
||||
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
|
||||
className={cn(
|
||||
'tracker-number cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-semibold',
|
||||
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
|
||||
isEmpty && 'text-muted-foreground',
|
||||
mismatch && 'text-amber-500',
|
||||
!isEmpty && !mismatch && 'text-emerald-500',
|
||||
)}
|
||||
>
|
||||
{displayVal}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||
const amountRef = useRef(null);
|
||||
|
||||
const threshold = useMemo(() => row.actual_amount != null ? row.actual_amount : row.expected_amount, [row]);
|
||||
const defaultPaymentDate = useMemo(() => paymentDateForTrackerMonth(year, month, row.due_day), [year, month, row.due_day]);
|
||||
const isPaidByThreshold = useMemo(() => row.total_paid > 0 && row.total_paid >= threshold, [row, threshold]);
|
||||
const isPaid = useMemo(() => row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold, [row.status, isPaidByThreshold]);
|
||||
const isSkipped = useMemo(() => !!row.is_skipped, [row.is_skipped]);
|
||||
|
||||
const effectiveStatus = useMemo(() => {
|
||||
if (isSkipped) return 'skipped';
|
||||
if (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') return 'paid';
|
||||
return row.status;
|
||||
}, [isSkipped, isPaidByThreshold, row.status]);
|
||||
|
||||
const rowBg = useMemo(() => isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''), [isSkipped, effectiveStatus]);
|
||||
const isUrgent = effectiveStatus === 'late' || effectiveStatus === 'missed';
|
||||
const remaining = useMemo(() => Math.max((threshold || 0) - (row.total_paid || 0), 0), [threshold, row.total_paid]);
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Marked as paid');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/70 bg-card/90 p-3 shadow-sm shadow-black/10',
|
||||
'space-y-3 transition-colors',
|
||||
isUrgent && 'border-border/80 shadow-md shadow-rose-950/10',
|
||||
isSkipped ? 'opacity-55' : rowBg,
|
||||
)}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-left text-[15px] font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
title="Edit bill"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
{row.autopay_enabled && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
|
||||
title="Autopay"
|
||||
>
|
||||
AP
|
||||
</span>
|
||||
)}
|
||||
{row.is_subscription && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
|
||||
title="Subscription"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
{row.monthly_notes}
|
||||
</p>
|
||||
)}
|
||||
{isUrgent && (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-rose-500 dark:text-rose-200">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Needs attention
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
||||
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
|
||||
{fmt(threshold)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||||
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
|
||||
{fmt(remaining)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Paid </span>
|
||||
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Date </span>
|
||||
<span className="tracker-number font-medium text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{!isPaid && !isSkipped && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
type="number" min="0" step="0.01"
|
||||
defaultValue={threshold}
|
||||
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
|
||||
title="Payment amount"
|
||||
aria-label={`${row.name} payment amount`}
|
||||
/>
|
||||
<Button
|
||||
size="sm" variant="default"
|
||||
onClick={handleQuickPay}
|
||||
className="h-8 px-3 text-xs font-semibold"
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Payment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Month
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
MobileTrackerRow.displayName = 'MobileTrackerRow';
|
||||
|
|
@ -7,7 +7,7 @@ import { api } from '@/api';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SectionCard } from './dataShared';
|
||||
import { SectionCard, importErrorState } from './dataShared';
|
||||
|
||||
const CSV_MAPPING_FIELDS = [
|
||||
'posted_date',
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ export default function StatusPage() {
|
|||
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
||||
const errors = data?.errors ?? data?.recent_errors ?? [];
|
||||
|
||||
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
||||
const dbOk = db.connected ?? (db.status ? db.status === 'connected' : true);
|
||||
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
||||
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
||||
const backupsEnabled = backups.enabled ?? null;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
|
||||
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
||||
"smoke:prod": "bash scripts/prod-smoke.sh",
|
||||
"ci": "npm run check:server && npm run test:all && npm run build",
|
||||
"ci": "npm run lint && npm run check:server && npm run test:all && npm run build",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue