Polish tracker command header
This commit is contained in:
parent
23f79c31e0
commit
40dfd6e3c1
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue