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 { 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';
|
||||
|
|
@ -91,6 +91,34 @@ interface TrackerFilters {
|
|||
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
|
||||
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 {
|
||||
const d = parseUtc(isoStr);
|
||||
if (!d) return null;
|
||||
|
|
@ -771,6 +799,22 @@ export default function TrackerPage() {
|
|||
const first = sortedRows.filter(r => r.bucket === '1st');
|
||||
const second = sortedRows.filter(r => r.bucket === '15th');
|
||||
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) {
|
||||
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
|
||||
|
|
@ -812,22 +856,70 @@ export default function TrackerPage() {
|
|||
|
||||
{/* ── Header ── */}
|
||||
<PageHeader
|
||||
eyebrow="Monthly overview"
|
||||
title={(
|
||||
eyebrow="Monthly command center"
|
||||
title="Tracker"
|
||||
description={(
|
||||
<>
|
||||
{MONTHS[month - 1]}
|
||||
<span className="ml-2 text-xl font-normal text-muted-foreground">{year}</span>
|
||||
{activeMonthLabel}
|
||||
<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"
|
||||
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={(
|
||||
<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
|
||||
size="sm"
|
||||
variant={pinUpcoming ? 'default' : 'outline'}
|
||||
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'}
|
||||
>
|
||||
<ArrowUpToLine className="h-4 w-4" />
|
||||
|
|
@ -840,7 +932,7 @@ export default function TrackerPage() {
|
|||
variant="outline"
|
||||
onClick={handleBankSync}
|
||||
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"
|
||||
>
|
||||
{bankSyncing
|
||||
|
|
@ -852,7 +944,7 @@ export default function TrackerPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
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"
|
||||
title="Add bill"
|
||||
>
|
||||
|
|
@ -864,7 +956,7 @@ export default function TrackerPage() {
|
|||
size="sm"
|
||||
variant="outline"
|
||||
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"
|
||||
title="View this month on the calendar"
|
||||
>
|
||||
|
|
@ -877,7 +969,7 @@ export default function TrackerPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
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"
|
||||
title="Export or print this month"
|
||||
>
|
||||
|
|
@ -904,7 +996,7 @@ export default function TrackerPage() {
|
|||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-9 w-9 tracker-print-hide"
|
||||
className="h-9 w-9 rounded-full tracker-print-hide"
|
||||
aria-label="Keyboard shortcuts"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
|
|
@ -933,38 +1025,6 @@ export default function TrackerPage() {
|
|||
</DropdownMenuContent>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue