refactor(tracker): remove dead code and unreferenced files (plan A1)
- Delete unreachable in-file dialogs: MonthlyStateDialog block (setShowMbs never called) and inline PaymentModal in TrackerRow + MobileTrackerRow (setEditPayment only ever null; editing works via PaymentLedgerDialog). - Delete fully-unreferenced files: tracker/EditableCell.tsx, ui/confirm-dialog.tsx, ui/separator.tsx (+ orphaned @radix-ui/react-separator). - Simplify OverdueCommandCenter dead branch (early-returns null when empty). - Correct stale roadmap/FUTURE docs: tracker keyboard nav is implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
db1be4b0bd
commit
3973560e92
10
FUTURE.md
10
FUTURE.md
|
|
@ -143,16 +143,16 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu
|
||||||
### 🔵 No Keyboard Navigation or Shortcuts — LOW
|
### 🔵 No Keyboard Navigation or Shortcuts — LOW
|
||||||
**Added:** 2026-05-11 by Ripley
|
**Added:** 2026-05-11 by Ripley
|
||||||
|
|
||||||
**Status:** Partial — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ❌
|
**Status:** Done — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ✅
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid.
|
Skip link, command palette, and tracker-row keyboard navigation are all implemented.
|
||||||
|
|
||||||
**Implementation Notes:**
|
**Implementation Notes:**
|
||||||
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
||||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`)
|
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`)
|
||||||
- ❌ Arrow keys navigate tracker rows when grid is focused
|
- ✅ Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`)
|
||||||
- Remaining effort: 1-2 hours
|
- Nice-to-have: a `?`-triggered shortcut legend for discoverability
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/api';
|
|
||||||
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
|
||||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import type { Dollars } from '@/lib/money';
|
|
||||||
import type { TrackerRow } from '@/types';
|
|
||||||
|
|
||||||
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
|
||||||
interface EditableCellProps {
|
|
||||||
row: TrackerRow;
|
|
||||||
field: 'amount' | 'date';
|
|
||||||
threshold: Dollars;
|
|
||||||
defaultPaymentDate: string;
|
|
||||||
refresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }: EditableCellProps) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const displayVal = field === 'amount'
|
|
||||||
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
|
||||||
: (row.last_paid_date ? fmtDate(row.last_paid_date) : '—');
|
|
||||||
|
|
||||||
const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
|
|
||||||
// Mismatch when paid amount differs from the effective threshold for this month
|
|
||||||
const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== 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: { amount?: number; paid_date?: string } = {};
|
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
|
||||||
if (field === 'date') update.paid_date = val;
|
|
||||||
await api.updatePayment(row.payments[0]!.id, update);
|
|
||||||
toast.success('Saved');
|
|
||||||
refresh();
|
|
||||||
} else {
|
|
||||||
await createPaymentOrConfirm(
|
|
||||||
{
|
|
||||||
bill_id: row.id,
|
|
||||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
|
||||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
|
||||||
},
|
|
||||||
() => { toast.success('Saved'); refresh(); },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(errMessage(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
||||||
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="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
onClick={startEdit}
|
|
||||||
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
|
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
|
||||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||||
|
|
@ -20,7 +19,7 @@ import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'
|
||||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||||
import type { Payment, TrackerRow } from '@/types';
|
import type { TrackerRow } from '@/types';
|
||||||
|
|
||||||
function MiniSparkline({ values }: { values?: number[] | null }) {
|
function MiniSparkline({ values }: { values?: number[] | null }) {
|
||||||
if (!values || values.length < 2) return null;
|
if (!values || values.length < 2) return null;
|
||||||
|
|
@ -78,7 +77,6 @@ interface MobileTrackerRowProps {
|
||||||
|
|
||||||
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) {
|
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) {
|
||||||
const amountRef = useRef<HTMLInputElement>(null);
|
const amountRef = useRef<HTMLInputElement>(null);
|
||||||
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||||
|
|
@ -378,15 +376,6 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{editPayment && (
|
|
||||||
<PaymentModal
|
|
||||||
payment={editPayment}
|
|
||||||
autopayEnabled={!!row.autopay_enabled}
|
|
||||||
onClose={() => setEditPayment(null)}
|
|
||||||
onSave={refresh}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentLedgerOpen && (
|
{paymentLedgerOpen && (
|
||||||
<PaymentLedgerDialog
|
<PaymentLedgerDialog
|
||||||
row={row}
|
row={row}
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/api';
|
|
||||||
import { fmt, errMessage } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import type { TrackerRow } from '@/types';
|
|
||||||
|
|
||||||
const MONTHS = [
|
|
||||||
'January','February','March','April','May','June',
|
|
||||||
'July','August','September','October','November','December',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MonthlyStateDialogProps {
|
|
||||||
row: TrackerRow;
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) {
|
|
||||||
const [actualAmount, setActualAmount] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [isSkipped, setIsSkipped] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Populate from current row state when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
|
|
||||||
setNotes(row.monthly_notes || '');
|
|
||||||
setIsSkipped(!!row.is_skipped);
|
|
||||||
}
|
|
||||||
}, [open, row]);
|
|
||||||
|
|
||||||
async function handleSave(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
|
|
||||||
if (amt !== null && (isNaN(amt) || amt < 0)) {
|
|
||||||
toast.error('Amount must be a positive number or empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await api.saveBillMonthlyState(row.id, {
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
actual_amount: amt,
|
|
||||||
notes: notes.trim() || null,
|
|
||||||
is_skipped: isSkipped,
|
|
||||||
});
|
|
||||||
toast.success(`${MONTHS[month - 1]} state saved`);
|
|
||||||
onSaved();
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(errMessage(err));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-base font-semibold tracking-tight">
|
|
||||||
{row.name}
|
|
||||||
<span className="text-muted-foreground font-normal ml-2">
|
|
||||||
{MONTHS[month - 1]} {year}
|
|
||||||
</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Monthly overrides — changes only affect {MONTHS[month - 1]}
|
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
|
|
||||||
{/* Actual amount this month */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
Actual Amount ($)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="mbs-amount"
|
|
||||||
type="number" min="0" step="0.01"
|
|
||||||
placeholder={String(row.expected_amount)}
|
|
||||||
value={actualAmount}
|
|
||||||
onChange={e => setActualAmount(e.target.value)}
|
|
||||||
className="font-mono bg-background/50 border-border/60"
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Leave blank to use the template default ({fmt(row.expected_amount)}).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Monthly notes */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
Notes (this month only)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="mbs-notes"
|
|
||||||
value={notes}
|
|
||||||
onChange={e => setNotes(e.target.value)}
|
|
||||||
placeholder="e.g. higher than usual, double-billed…"
|
|
||||||
className="bg-background/50 border-border/60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skip this month */}
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSkipped}
|
|
||||||
onChange={e => setIsSkipped(e.target.checked)}
|
|
||||||
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium leading-tight">Skip this month</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
|
||||||
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MonthlyStateDialog;
|
|
||||||
|
|
@ -193,15 +193,11 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0 text-rose-400" />
|
<AlertCircle className="h-4 w-4 shrink-0 text-rose-400" />
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{overdueRows.length === 0
|
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
||||||
? 'No active overdue bills'
|
</span>
|
||||||
: `${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
<span className="font-mono text-sm text-rose-400 dark:text-rose-300">
|
||||||
|
{fmt(asDollars(totalOverdue))}
|
||||||
</span>
|
</span>
|
||||||
{overdueRows.length > 0 && (
|
|
||||||
<span className="font-mono text-sm text-rose-400 dark:text-rose-300">
|
|
||||||
{fmt(asDollars(totalOverdue))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{snoozedRows.length > 0 && (
|
{snoozedRows.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
|
||||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
|
||||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||||
|
|
@ -24,7 +22,7 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||||
import type { Payment, TrackerRow as TrackerRowData } from '@/types';
|
import type { TrackerRow as TrackerRowData } from '@/types';
|
||||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||||
|
|
||||||
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
|
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
|
||||||
|
|
@ -46,9 +44,7 @@ interface TrackerRowProps {
|
||||||
|
|
||||||
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) {
|
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) {
|
||||||
const amountRef = useRef<HTMLInputElement>(null);
|
const amountRef = useRef<HTMLInputElement>(null);
|
||||||
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
const [showMbs, setShowMbs] = useState(false);
|
|
||||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||||
const [optimisticActual, setOptimisticActual] = useState<Dollars | undefined>(undefined);
|
const [optimisticActual, setOptimisticActual] = useState<Dollars | undefined>(undefined);
|
||||||
|
|
@ -690,15 +686,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{editPayment && (
|
|
||||||
<PaymentModal
|
|
||||||
payment={editPayment}
|
|
||||||
autopayEnabled={!!row.autopay_enabled}
|
|
||||||
onClose={() => setEditPayment(null)}
|
|
||||||
onSave={refresh}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentLedgerOpen && (
|
{paymentLedgerOpen && (
|
||||||
<PaymentLedgerDialog
|
<PaymentLedgerDialog
|
||||||
row={row}
|
row={row}
|
||||||
|
|
@ -711,17 +698,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showMbs && (
|
|
||||||
<MonthlyStateDialog
|
|
||||||
row={row}
|
|
||||||
year={year}
|
|
||||||
month={month}
|
|
||||||
open={showMbs}
|
|
||||||
onOpenChange={setShowMbs}
|
|
||||||
onSaved={refresh}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
type ConfirmVariant = 'default' | 'destructive';
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
title?: string;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
variant?: ConfirmVariant;
|
|
||||||
onConfirm?: () => void | Promise<void>;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ConfirmDialog ──────────────────────────────────────────────
|
|
||||||
Reusable confirmation dialog that replaces window.confirm. */
|
|
||||||
|
|
||||||
export function ConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = 'Confirm',
|
|
||||||
cancelLabel = 'Cancel',
|
|
||||||
variant = 'default',
|
|
||||||
onConfirm,
|
|
||||||
loading = false,
|
|
||||||
}: ConfirmDialogProps) {
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
await onConfirm?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (!loading) onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={loading ? undefined : onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
{description && (
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
className={cn(loading && 'cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── useConfirm hook ────────────────────────────────────────────
|
|
||||||
Imperative API for ConfirmDialog.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
|
||||||
const ok = await confirm({ title, description, confirmLabel, variant });
|
|
||||||
// Render <ConfirmDialogComponent /> anywhere in the tree. */
|
|
||||||
|
|
||||||
interface ConfirmState {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
description: React.ReactNode;
|
|
||||||
confirmLabel: string;
|
|
||||||
cancelLabel: string;
|
|
||||||
variant: ConfirmVariant;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmOptions {
|
|
||||||
title?: string;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
variant?: ConfirmVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConfirm() {
|
|
||||||
const [state, setState] = React.useState<ConfirmState>({
|
|
||||||
open: false,
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
confirmLabel: 'Confirm',
|
|
||||||
cancelLabel: 'Cancel',
|
|
||||||
variant: 'default',
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep a stable ref to the resolve callback so it survives re-renders
|
|
||||||
const resolveRef = React.useRef<((value: boolean) => void) | null>(null);
|
|
||||||
|
|
||||||
const confirm = React.useCallback(
|
|
||||||
({
|
|
||||||
title = 'Are you sure?',
|
|
||||||
description = '',
|
|
||||||
confirmLabel = 'Confirm',
|
|
||||||
cancelLabel = 'Cancel',
|
|
||||||
variant = 'default',
|
|
||||||
}: ConfirmOptions = {}): Promise<boolean> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolveRef.current = resolve;
|
|
||||||
setState({
|
|
||||||
open: true,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel,
|
|
||||||
cancelLabel,
|
|
||||||
variant,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirm = React.useCallback(async () => {
|
|
||||||
setState((s) => ({ ...s, loading: true }));
|
|
||||||
resolveRef.current?.(true);
|
|
||||||
resolveRef.current = null;
|
|
||||||
setState((s) => ({ ...s, open: false, loading: false }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
resolveRef.current?.(false);
|
|
||||||
resolveRef.current = null;
|
|
||||||
setState((s) => ({ ...s, open: false }));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ConfirmDialogComponent = React.useCallback(
|
|
||||||
() => (
|
|
||||||
<ConfirmDialog
|
|
||||||
open={state.open}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
title={state.title}
|
|
||||||
description={state.description}
|
|
||||||
confirmLabel={state.confirmLabel}
|
|
||||||
cancelLabel={state.cancelLabel}
|
|
||||||
variant={state.variant}
|
|
||||||
loading={state.loading}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[state, handleConfirm, handleOpenChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { confirm, ConfirmDialogComponent };
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }: ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 bg-border',
|
|
||||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
|
@ -4512,52 +4511,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
|
||||||
"version": "1.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
|
||||||
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-primitive": "2.1.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-slot": "1.2.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
|
|
||||||
13
roadmap.md
13
roadmap.md
|
|
@ -107,20 +107,19 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔵 No Keyboard Navigation or Shortcuts — LOW
|
### 🔵 No Keyboard Navigation or Shortcuts — DONE
|
||||||
**Status:** Partially implemented
|
**Status:** Implemented
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid.
|
A skip link, command palette, and tracker-row keyboard navigation all exist.
|
||||||
|
|
||||||
**Implementation Status:**
|
**Implementation Status:**
|
||||||
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
||||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`)
|
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`)
|
||||||
- ❌ Arrow keys navigate tracker rows when grid is focused
|
- ✅ Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`)
|
||||||
|
|
||||||
**Remaining Work:**
|
**Remaining Work:**
|
||||||
- Implement arrow key navigation for tracker rows
|
- None. A `?`-triggered shortcut legend for discoverability is a possible nice-to-have.
|
||||||
- Estimated effort: 1-2 hours
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue