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(() => {
|
const autopayClass = useMemo(() => {
|
||||||
return cn(
|
return cn(
|
||||||
'rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300',
|
'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]);
|
}, [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 { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { SectionCard } from './dataShared';
|
import { SectionCard, importErrorState } from './dataShared';
|
||||||
|
|
||||||
const CSV_MAPPING_FIELDS = [
|
const CSV_MAPPING_FIELDS = [
|
||||||
'posted_date',
|
'posted_date',
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ export default function StatusPage() {
|
||||||
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
||||||
const errors = data?.errors ?? data?.recent_errors ?? [];
|
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 workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
||||||
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
||||||
const backupsEnabled = backups.enabled ?? 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: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",
|
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
||||||
"smoke:prod": "bash scripts/prod-smoke.sh",
|
"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"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue