Polish tracker command header

This commit is contained in:
null 2026-07-06 12:54:11 -05:00
parent 23f79c31e0
commit 40dfd6e3c1
1 changed files with 104 additions and 44 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react'; import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ComponentType, type ReactNode } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays, Keyboard } from 'lucide-react'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays, Keyboard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -91,6 +91,34 @@ interface TrackerFilters {
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt'; type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
type Patch = Record<string, string | number | boolean | null | undefined>; type Patch = Record<string, string | number | boolean | null | undefined>;
function HeaderStat({
icon: Icon,
label,
value,
tone = 'neutral',
}: {
icon: ComponentType<{ className?: string }>;
label: string;
value: ReactNode;
tone?: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
}) {
const toneClass = {
neutral: 'border-border/70 bg-card/65 text-muted-foreground',
good: 'border-emerald-500/25 bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-300',
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
info: 'border-sky-500/25 bg-sky-500/[0.08] text-sky-700 dark:text-sky-300',
}[tone];
return (
<span className={cn('inline-flex items-center gap-2 rounded-full border px-3 py-1.5', toneClass)}>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{label}</span>
<span className="tracker-number text-xs font-semibold text-foreground">{value}</span>
</span>
);
}
function fmtBalanceAge(isoStr: string | null | undefined): string | null { function fmtBalanceAge(isoStr: string | null | undefined): string | null {
const d = parseUtc(isoStr); const d = parseUtc(isoStr);
if (!d) return null; if (!d) return null;
@ -771,6 +799,22 @@ export default function TrackerPage() {
const first = sortedRows.filter(r => r.bucket === '1st'); const first = sortedRows.filter(r => r.bucket === '1st');
const second = sortedRows.filter(r => r.bucket === '15th'); const second = sortedRows.filter(r => r.bucket === '15th');
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming; const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
const activeMonthLabel = `${MONTHS[month - 1]} ${year}`;
const monthStartLabel = new Date(year, month - 1, 1).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const monthEndLabel = new Date(year, month, 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const paidCount = Number(summary.count_paid ?? 0);
const overdueCount = Number(summary.count_late ?? 0);
const unpaidCount = rows.filter(row => !row.is_skipped && !rowIsPaid(row)).length;
const headerStatusTone = isError ? 'danger' : loading ? 'info' : overdueCount > 0 ? 'danger' : unpaidCount > 0 ? 'warn' : 'good';
const headerStatusText = isError
? 'Needs attention'
: loading
? 'Loading month'
: overdueCount > 0
? `${overdueCount} overdue`
: unpaidCount > 0
? `${unpaidCount} unpaid`
: 'All settled';
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) { async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index])); const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
@ -812,22 +856,70 @@ export default function TrackerPage() {
{/* ── Header ── */} {/* ── Header ── */}
<PageHeader <PageHeader
eyebrow="Monthly overview" eyebrow="Monthly command center"
title={( title="Tracker"
description={(
<> <>
{MONTHS[month - 1]} {activeMonthLabel}
<span className="ml-2 text-xl font-normal text-muted-foreground">{year}</span> <span className="mx-2 text-muted-foreground/60">·</span>
{monthStartLabel} to {monthEndLabel}
{isCurrentMonth && (
<>
<span className="mx-2 text-muted-foreground/60">·</span>
Current month
</>
)}
</> </>
)} )}
glyph="tracker" glyph="tracker"
meta={`${rows.length} ${rows.length === 1 ? 'bill' : 'bills'}`} meta={(
<div className="flex flex-wrap items-center gap-2">
<HeaderStat icon={CheckCircle2} label="Paid" value={loading ? '...' : paidCount} tone={paidCount > 0 ? 'good' : 'neutral'} />
<HeaderStat icon={AlertCircle} label="Needs action" value={loading ? '...' : headerStatusText} tone={headerStatusTone} />
<HeaderStat icon={Search} label="Showing" value={`${filteredRows.length}/${rows.length}`} tone={hasFilters ? 'info' : 'neutral'} />
<HeaderStat icon={RefreshCw} label="Remaining" value={loading ? '...' : fmt(summary.remaining)} tone={Number(summary.remaining ?? 0) <= 0 ? 'good' : 'neutral'} />
</div>
)}
actions={( actions={(
<div className="flex flex-wrap items-center gap-2 tracker-print-hide"> <div className="flex flex-wrap items-center gap-2 tracker-print-hide">
<div className="flex items-center gap-1 rounded-full border border-border/75 bg-card/70 p-1 shadow-sm shadow-black/5">
<Button
size="icon" variant="ghost"
onClick={() => navigate(-1)}
onMouseEnter={() => prefetchAdjacent(-1)}
onFocus={() => prefetchAdjacent(-1)}
className="h-8 w-8 rounded-full"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[8rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
{activeMonthLabel}
</span>
<Button
size="icon" variant="ghost"
onClick={() => navigate(1)}
onMouseEnter={() => prefetchAdjacent(1)}
onFocus={() => prefetchAdjacent(1)}
className="h-8 w-8 rounded-full"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
size="sm" variant={isCurrentMonth ? 'secondary' : 'outline'}
onClick={goToday}
className="h-9 rounded-full px-3 text-xs"
>
Today
</Button>
<Button <Button
size="sm" size="sm"
variant={pinUpcoming ? 'default' : 'outline'} variant={pinUpcoming ? 'default' : 'outline'}
onClick={togglePinUpcoming} onClick={togglePinUpcoming}
className="h-9 gap-1.5 px-3" className="h-9 gap-1.5 rounded-full px-3"
title={pinUpcoming ? 'Showing urgent bills first — click to restore normal order' : 'Pin overdue and due-soon bills to the top'} title={pinUpcoming ? 'Showing urgent bills first — click to restore normal order' : 'Pin overdue and due-soon bills to the top'}
> >
<ArrowUpToLine className="h-4 w-4" /> <ArrowUpToLine className="h-4 w-4" />
@ -840,7 +932,7 @@ export default function TrackerPage() {
variant="outline" variant="outline"
onClick={handleBankSync} onClick={handleBankSync}
disabled={bankSyncing} disabled={bankSyncing}
className="h-9 gap-1.5 px-3" className="h-9 gap-1.5 rounded-full px-3"
title="Scan bank transactions and match payments" title="Scan bank transactions and match payments"
> >
{bankSyncing {bankSyncing
@ -852,7 +944,7 @@ export default function TrackerPage() {
<Button <Button
size="sm" size="sm"
onClick={handleOpenAddBill} onClick={handleOpenAddBill}
className="h-9 gap-1.5 px-3 shadow-sm" className="h-9 gap-1.5 rounded-full px-3 shadow-sm"
aria-label="Add bill" aria-label="Add bill"
title="Add bill" title="Add bill"
> >
@ -864,7 +956,7 @@ export default function TrackerPage() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={goToCalendar} onClick={goToCalendar}
className="h-9 gap-1.5 px-3 tracker-print-hide" className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
aria-label="Open calendar for this month" aria-label="Open calendar for this month"
title="View this month on the calendar" title="View this month on the calendar"
> >
@ -877,7 +969,7 @@ export default function TrackerPage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-9 gap-1.5 px-3 tracker-print-hide" className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
aria-label="Export or print this month" aria-label="Export or print this month"
title="Export or print this month" title="Export or print this month"
> >
@ -904,7 +996,7 @@ export default function TrackerPage() {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-9 w-9 tracker-print-hide" className="h-9 w-9 rounded-full tracker-print-hide"
aria-label="Keyboard shortcuts" aria-label="Keyboard shortcuts"
title="Keyboard shortcuts" title="Keyboard shortcuts"
> >
@ -933,38 +1025,6 @@ export default function TrackerPage() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button
size="icon" variant="ghost"
onClick={() => navigate(-1)}
onMouseEnter={() => prefetchAdjacent(-1)}
onFocus={() => prefetchAdjacent(-1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
{MONTHS[month - 1]} {year}
</span>
<Button
size="icon" variant="ghost"
onClick={() => navigate(1)}
onMouseEnter={() => prefetchAdjacent(1)}
onFocus={() => prefetchAdjacent(1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
size="sm" variant="outline"
onClick={goToday}
className="h-9 px-3 text-xs"
>
Today
</Button>
</div> </div>
)} )}
/> />