diff --git a/client/components/tracker/EditableCell.jsx b/client/components/tracker/EditableCell.tsx similarity index 81% rename from client/components/tracker/EditableCell.jsx rename to client/components/tracker/EditableCell.tsx index 29954e7..4a0d7a7 100644 --- a/client/components/tracker/EditableCell.jsx +++ b/client/components/tracker/EditableCell.tsx @@ -1,14 +1,24 @@ import { useState, useRef } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt, fmtDate } from '@/lib/utils'; +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 -export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) { +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(null); + const inputRef = useRef(null); const displayVal = field === 'amount' ? (row.total_paid > 0 ? fmt(row.total_paid) : '—') @@ -33,10 +43,10 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres if (!val) return; try { if (row.payments && row.payments.length > 0) { - const update = {}; + 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); + await api.updatePayment(row.payments[0]!.id, update); } else { await api.createPayment({ bill_id: row.id, @@ -47,11 +57,11 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres toast.success('Saved'); refresh(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); } } - function onKeyDown(e) { + function onKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Escape') { setValue(''); setEditing(false); } } diff --git a/client/components/tracker/LowerThisMonthButton.jsx b/client/components/tracker/LowerThisMonthButton.tsx similarity index 80% rename from client/components/tracker/LowerThisMonthButton.jsx rename to client/components/tracker/LowerThisMonthButton.tsx index e6e47d4..6d82a23 100644 --- a/client/components/tracker/LowerThisMonthButton.jsx +++ b/client/components/tracker/LowerThisMonthButton.tsx @@ -1,11 +1,20 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils'; +import type { TrackerRow } from '@/types'; -export function LowerThisMonthButton({ row, year, month, refresh, compact = false }) { +interface LowerThisMonthButtonProps { + row: TrackerRow; + year: number; + month: number; + refresh?: () => void; + compact?: boolean; +} + +export function LowerThisMonthButton({ row, year, month, refresh, compact = false }: LowerThisMonthButtonProps) { const threshold = rowThreshold(row); const summary = paymentSummary(row, threshold); const [saving, setSaving] = useState(false); @@ -25,7 +34,7 @@ export function LowerThisMonthButton({ row, year, month, refresh, compact = fals toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to update monthly amount'); + toast.error(errMessage(err, 'Failed to update monthly amount')); } finally { setSaving(false); } diff --git a/client/components/tracker/NotesCell.jsx b/client/components/tracker/NotesCell.tsx similarity index 80% rename from client/components/tracker/NotesCell.jsx rename to client/components/tracker/NotesCell.tsx index 77d3f05..cb584eb 100644 --- a/client/components/tracker/NotesCell.jsx +++ b/client/components/tracker/NotesCell.tsx @@ -1,10 +1,17 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; +import type { TrackerRow } from '@/types'; -// Monthly notes stored in monthly_bill_state — per-month, not per-bill. -export function NotesCell({ row, refresh }) { +// Monthly notes stored in monthly_bill_state — per-month, not per-bill. The +// tracker page injects year/month onto the row for this cell's save. +interface NotesCellProps { + row: TrackerRow & { year?: number; month?: number }; + refresh: () => void; +} + +export function NotesCell({ row, refresh }: NotesCellProps) { const savedNote = row.monthly_notes || ''; const [value, setValue] = useState(savedNote); const [saving, setSaving] = useState(false); @@ -33,7 +40,7 @@ export function NotesCell({ row, refresh }) { }); refresh(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); setValue(savedNote); } finally { setSaving(false); } } diff --git a/client/components/ui/Skeleton.jsx b/client/components/ui/Skeleton.tsx similarity index 67% rename from client/components/ui/Skeleton.jsx rename to client/components/ui/Skeleton.tsx index 8692657..e851941 100644 --- a/client/components/ui/Skeleton.jsx +++ b/client/components/ui/Skeleton.tsx @@ -1,7 +1,10 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -function Skeleton({ className, variant = 'line', ref, ...props }) { - const variants = { +type SkeletonVariant = 'line' | 'circle' | 'card' | 'button' | 'input'; + +function Skeleton({ className, variant = 'line', ref, ...props }: ComponentProps<'div'> & { variant?: SkeletonVariant }) { + const variants: Record = { line: 'h-4 w-full rounded-md', circle: 'h-10 w-10 rounded-full', card: 'h-24 w-full rounded-xl', diff --git a/client/components/ui/alert-dialog.jsx b/client/components/ui/alert-dialog.tsx similarity index 78% rename from client/components/ui/alert-dialog.jsx rename to client/components/ui/alert-dialog.tsx index 880939a..70f74d7 100644 --- a/client/components/ui/alert-dialog.jsx +++ b/client/components/ui/alert-dialog.tsx @@ -1,6 +1,6 @@ import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -import { buttonVariants } from '@/components/ui/button'; const AlertDialog = AlertDialogPrimitive.Root; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -8,7 +8,7 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogCancel = AlertDialogPrimitive.Cancel; const AlertDialogAction = AlertDialogPrimitive.Action; -function AlertDialogOverlay({ className, ...props }) { +function AlertDialogOverlay({ className, ...props }: ComponentProps) { return ( ) { return ( @@ -42,17 +42,17 @@ function AlertDialogContent({ className, children, ...props }) { ); } -function AlertDialogHeader({ className, ...props }) { +function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>) { return
; } -function AlertDialogFooter({ className, ...props }) { +function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) { return (
); } -function AlertDialogTitle({ className, ...props }) { +function AlertDialogTitle({ className, ...props }: ComponentProps) { return ( ) { return ( & VariantProps) { return (
); diff --git a/client/components/ui/button.jsx b/client/components/ui/button.tsx similarity index 83% rename from client/components/ui/button.jsx rename to client/components/ui/button.tsx index 4cf7256..f26e111 100644 --- a/client/components/ui/button.jsx +++ b/client/components/ui/button.tsx @@ -1,5 +1,6 @@ import { Slot } from '@radix-ui/react-slot'; -import { cva } from 'class-variance-authority'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, ElementType } from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( @@ -28,8 +29,10 @@ const buttonVariants = cva( } ); -function Button({ className, variant, size, asChild = false, ref, ...props }) { - const Comp = asChild ? Slot : 'button'; +type ButtonProps = ComponentProps<'button'> & VariantProps & { asChild?: boolean }; + +function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) { + const Comp: ElementType = asChild ? Slot : 'button'; return ( ) { return (
) { return (
) { return (
) { return (
) { return (
) { return (
) { return ( ) { return ( void - title string - description string | ReactNode - confirmLabel string (default: 'Confirm') - cancelLabel string (default: 'Cancel') - variant 'default' | 'destructive' - onConfirm () => void | Promise - loading boolean - ─────────────────────────────────────────────────────────────── */ +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description?: React.ReactNode; + confirmLabel?: string; + cancelLabel?: string; + variant?: ConfirmVariant; + onConfirm?: () => void | Promise; + loading?: boolean; +} + +/* ── ConfirmDialog ────────────────────────────────────────────── + Reusable confirmation dialog that replaces window.confirm. */ export function ConfirmDialog({ open, @@ -36,7 +38,7 @@ export function ConfirmDialog({ variant = 'default', onConfirm, loading = false, -}) { +}: ConfirmDialogProps) { const handleConfirm = async () => { await onConfirm?.(); }; @@ -84,14 +86,29 @@ export function ConfirmDialog({ Usage: const { confirm, ConfirmDialogComponent } = useConfirm(); - // ... const ok = await confirm({ title, description, confirmLabel, variant }); - // Returns true if confirmed, false if cancelled. - // Render anywhere in the tree. - ─────────────────────────────────────────────────────────────── */ + // Render 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({ + const [state, setState] = React.useState({ open: false, title: '', description: '', @@ -102,7 +119,7 @@ export function useConfirm() { }); // Keep a stable ref to the resolve callback so it survives re-renders - const resolveRef = React.useRef(null); + const resolveRef = React.useRef<((value: boolean) => void) | null>(null); const confirm = React.useCallback( ({ @@ -111,7 +128,7 @@ export function useConfirm() { confirmLabel = 'Confirm', cancelLabel = 'Cancel', variant = 'default', - } = {}) => { + }: ConfirmOptions = {}): Promise => { return new Promise((resolve) => { resolveRef.current = resolve; setState({ @@ -135,7 +152,7 @@ export function useConfirm() { setState((s) => ({ ...s, open: false, loading: false })); }, []); - const handleOpenChange = React.useCallback((open) => { + const handleOpenChange = React.useCallback((open: boolean) => { if (!open) { resolveRef.current?.(false); resolveRef.current = null; diff --git a/client/components/ui/dialog.jsx b/client/components/ui/dialog.tsx similarity index 82% rename from client/components/ui/dialog.jsx rename to client/components/ui/dialog.tsx index d1c303b..68c4e3f 100644 --- a/client/components/ui/dialog.jsx +++ b/client/components/ui/dialog.tsx @@ -1,6 +1,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { motion } from 'framer-motion'; import { X } from 'lucide-react'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const Dialog = DialogPrimitive.Root; @@ -8,7 +9,7 @@ const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; -function DialogOverlay({ className, ref, ...props }) { +function DialogOverlay({ className, ref, ...props }: ComponentProps) { return ( ) { return ( @@ -53,7 +54,7 @@ function DialogContent({ className, children, ref, ...props }) { ); } -function DialogHeader({ className, ...props }) { +function DialogHeader({ className, ...props }: ComponentProps<'div'>) { return (
) { return (
) { return ( ) { return ( & { inset?: boolean }) { return ( ) { return ( ) { return ( & { inset?: boolean; destructive?: boolean }) { return ( ) { return ( ) { return ( & { inset?: boolean }) { return ( ) { return ( ) { return ( ); diff --git a/client/components/ui/input-dialog.jsx b/client/components/ui/input-dialog.tsx similarity index 85% rename from client/components/ui/input-dialog.jsx rename to client/components/ui/input-dialog.tsx index 29e2679..0d8d446 100644 --- a/client/components/ui/input-dialog.jsx +++ b/client/components/ui/input-dialog.tsx @@ -17,6 +17,21 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +interface InputDialogProps { + open: boolean; + onOpenChange?: (open: boolean) => void; + title?: string; + description?: string; + label?: string; + defaultValue?: string; + placeholder?: string; + confirmLabel?: string; + cancelLabel?: string; + validate?: (value: string) => string | null; + onConfirm?: (value: string) => void; + loading?: boolean; +} + export function InputDialog({ open, onOpenChange, @@ -30,10 +45,10 @@ export function InputDialog({ validate, // optional (value: string) => string | null (null = valid) onConfirm, loading = false, -}) { +}: InputDialogProps) { const [value, setValue] = useState(defaultValue); const [error, setError] = useState(''); - const inputRef = useRef(null); + const inputRef = useRef(null); // Reset value when dialog opens useEffect(() => { @@ -58,7 +73,7 @@ export function InputDialog({ onConfirm?.(trimmed); }; - const handleKeyDown = (e) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); } if (e.key === 'Escape') onOpenChange?.(false); }; diff --git a/client/components/ui/input.jsx b/client/components/ui/input.tsx similarity index 84% rename from client/components/ui/input.jsx rename to client/components/ui/input.tsx index fcd0b4e..be860c8 100644 --- a/client/components/ui/input.jsx +++ b/client/components/ui/input.tsx @@ -1,6 +1,7 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -function Input({ className, type, ref, ...props }) { +function Input({ className, type, ref, ...props }: ComponentProps<'input'>) { return ( ) { return ( = { saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' }, saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' }, error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' }, @@ -12,8 +18,8 @@ const STATES = { * Tiny animated pill that reflects auto-save state. Renders nothing while idle — * the page communicates "changes save automatically" once, statically. */ -export function SaveStatus({ status, className }) { - const state = STATES[status]; +export function SaveStatus({ status, className }: { status?: string | null; className?: string }) { + const state = status ? STATES[status] : undefined; return ( {state && ( diff --git a/client/components/ui/select.jsx b/client/components/ui/select.tsx similarity index 84% rename from client/components/ui/select.jsx rename to client/components/ui/select.tsx index 7bc742d..e7c19ac 100644 --- a/client/components/ui/select.jsx +++ b/client/components/ui/select.tsx @@ -1,12 +1,13 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const Select = SelectPrimitive.Root; const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; -function SelectTrigger({ className, children, ref, ...props }) { +function SelectTrigger({ className, children, ref, ...props }: ComponentProps) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return (
) { return ( ) { return ( ) { return ( ) { return ( ) { return (
) { return ( ) { return (
) { return ( ) { return ( ) { return ( ) { return ( & { inset?: boolean }) { return ( t.value === theme) ?? THEMES[1]; + const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]!; const CurrentIcon = current.Icon; return ( diff --git a/client/components/ui/tooltip.jsx b/client/components/ui/tooltip.tsx similarity index 90% rename from client/components/ui/tooltip.jsx rename to client/components/ui/tooltip.tsx index b16c921..bbc1296 100644 --- a/client/components/ui/tooltip.jsx +++ b/client/components/ui/tooltip.tsx @@ -1,11 +1,12 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const TooltipProvider = TooltipPrimitive.Provider; const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; -function TooltipContent({ className, sideOffset = 4, ref, ...props }) { +function TooltipContent({ className, sideOffset = 4, ref, ...props }: ComponentProps) { return ( void; +} -export function ThemeProvider({ children }) { - const [theme, setThemeState] = useState(() => { +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { const stored = loadStoredTheme(); // Apply immediately (before first paint) to avoid flash applyTheme(stored); return stored; }); - const setTheme = (newTheme) => { - if (!VALID_THEMES.includes(newTheme)) return; - applyTheme(newTheme); - setThemeState(newTheme); + const setTheme = (newTheme: string) => { + if (!(VALID_THEMES as string[]).includes(newTheme)) return; + applyTheme(newTheme as Theme); + setThemeState(newTheme as Theme); try { localStorage.setItem(STORAGE_KEY, newTheme); } catch { diff --git a/client/hooks/usePaymentActions.ts b/client/hooks/usePaymentActions.ts index ca479af..bf0e5e0 100644 --- a/client/hooks/usePaymentActions.ts +++ b/client/hooks/usePaymentActions.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '@/api'; -import { fmt } from '@/lib/utils'; +import { fmt, errMessage } from '@/lib/utils'; import type { Dollars } from '@/lib/money'; import { useInvalidateTrackerData } from '@/hooks/useQueries'; @@ -11,11 +11,6 @@ interface PayableRow { name: string; } -/** Narrow an unknown error (strict catch clauses) to a message string. */ -function errMessage(err: unknown, fallback: string): string { - return err instanceof Error && err.message ? err.message : fallback; -} - // Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a // reversible Undo toast. A React Query mutation: `isPending` comes for free and // the tracker/badge caches are invalidated on settle. Shared by the desktop and diff --git a/client/lib/utils.ts b/client/lib/utils.ts index 73ec192..00461b2 100644 --- a/client/lib/utils.ts +++ b/client/lib/utils.ts @@ -14,6 +14,12 @@ export function fmt(amount: Parameters[0]): string { return formatUSD(amount); } +// Narrow an unknown error (strict catch clauses give `unknown`) to a message +// string, falling back when the thrown value isn't an Error with a message. +export function errMessage(err: unknown, fallback = 'Something went wrong'): string { + return err instanceof Error && err.message ? err.message : fallback; +} + export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; const [y = '', m = '', d = ''] = dateStr.split('-'); diff --git a/client/types.ts b/client/types.ts index cf8a0df..2416624 100644 --- a/client/types.ts +++ b/client/types.ts @@ -152,7 +152,9 @@ export interface TrackerRow { actual_amount: Dollars | null; is_skipped: boolean; snoozed_until: string | null; + monthly_notes?: string | null; autopay_suggestion?: unknown; + amount_suggestion?: unknown; previous_month_paid: Dollars; // Bank-mode augmentation (present only when bank_tracking.enabled): pending_cleared?: boolean;