feat(tracker): export/print the month + calendar link (plan C1/C2)
- Header "Export" menu: Export CSV (via the existing /api/export?from&to date range for the viewed month) and Print (window.print()). Extract the shared downloadFile blob helper to lib/download.ts (reused by DownloadMyDataSection). - Add api.exportRangeUrl(from, to, fmt). - Tracker-scoped @media print CSS: hide interactive chrome (.tracker-print-hide), keep the month title + summary + buckets, avoid splitting a row across pages. - Header "Calendar" button deep-links to /calendar?year&month; CalendarPage now reads those params as its initial month (falls back to today). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
313aafcb79
commit
88874f3f3d
|
|
@ -409,6 +409,9 @@ export const api = {
|
|||
|
||||
// Export (returns a URL to navigate to, not a fetch)
|
||||
exportUrl: (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`,
|
||||
// Export a specific date range (both bounds inclusive) — used to export one
|
||||
// tracker month. The /api/export route accepts ?from&to in addition to ?year.
|
||||
exportRangeUrl: (from: string, to: string, fmt?: string): string => `/api/export?from=${from}&to=${to}&format=${fmt||'csv'}`,
|
||||
|
||||
// Spreadsheet Import
|
||||
previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => {
|
||||
|
|
|
|||
|
|
@ -5,27 +5,9 @@ import { Button } from '@/components/ui/button';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn, errMessage } from '@/lib/utils';
|
||||
import { downloadFile } from '@/lib/download';
|
||||
import { SectionCard, type SectionCardProps } from './dataShared';
|
||||
|
||||
// Shared blob download: works whether or not the response sets Content-Disposition
|
||||
// (the JSON payments export doesn't), falling back to the given name.
|
||||
async function downloadFile(endpoint: string, fallbackName: string): Promise<void> {
|
||||
const res = await fetch(endpoint, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
let data: { message?: string; error?: string } = {};
|
||||
try { data = await res.json(); } catch { /* ignore */ }
|
||||
throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
const match = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
const name = match?.[1] || fallbackName;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function ExportCard({ icon: Icon, title, description, filename, endpoint }: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -229,10 +229,16 @@
|
|||
.summary-actions,
|
||||
.summary-reorder-controls,
|
||||
.summary-edit-actions,
|
||||
.summary-income-form {
|
||||
.summary-income-form,
|
||||
.tracker-print-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Tracker printout: don't split a bill row across a page boundary. */
|
||||
[data-tracker-row] {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
main,
|
||||
main > div {
|
||||
max-width: none !important;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
// Shared blob download: fetches an endpoint and saves the response as a file.
|
||||
// Works whether or not the response sets Content-Disposition (some exports
|
||||
// don't), falling back to the given name. Throws on a non-OK response so callers
|
||||
// can surface the failure with a toast.
|
||||
export async function downloadFile(endpoint: string, fallbackName: string): Promise<void> {
|
||||
const res = await fetch(endpoint, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
let data: { message?: string; error?: string } = {};
|
||||
try { data = await res.json(); } catch { /* ignore */ }
|
||||
throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
const match = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
const name = match?.[1] || fallbackName;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Banknote,
|
||||
CalendarDays,
|
||||
|
|
@ -902,7 +902,13 @@ function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const initial = currentMonth();
|
||||
const [searchParams] = useSearchParams();
|
||||
// Land on ?year=&month= when deep-linked (e.g. from the tracker), else today.
|
||||
const initial = (() => {
|
||||
const y = Number(searchParams.get('year'));
|
||||
const m = Number(searchParams.get('month'));
|
||||
return y && m >= 1 && m <= 12 ? { year: y, month: m } : currentMonth();
|
||||
})();
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [data, setData] = useState<CalendarData | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-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 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries';
|
||||
|
|
@ -9,6 +9,7 @@ import BillModal from '@/components/BillModal';
|
|||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
|
||||
import { downloadFile } from '@/lib/download';
|
||||
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -21,7 +22,7 @@ import {
|
|||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
|
||||
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
|
|
@ -263,6 +264,7 @@ const TRACKER_SETTING_DEFAULTS: Record<string, string> = {
|
|||
|
||||
export default function TrackerPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigateTo = useNavigate();
|
||||
const now = new Date();
|
||||
|
||||
// All navigation + filter state lives in the URL so views are bookmarkable/shareable.
|
||||
|
|
@ -452,6 +454,26 @@ export default function TrackerPage() {
|
|||
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
|
||||
}
|
||||
|
||||
// Export the viewed month's activity as CSV via the existing /api/export
|
||||
// date-range endpoint (both bounds inclusive).
|
||||
async function handleExportMonthCsv() {
|
||||
const mm = String(month).padStart(2, '0');
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const from = `${year}-${mm}-01`;
|
||||
const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
try {
|
||||
await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`);
|
||||
toast.success(`Exported ${from} – ${to} to CSV.`);
|
||||
} catch (err) {
|
||||
toast.error((err as { message?: string })?.message || 'Export failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to the calendar view for the month currently in view.
|
||||
function goToCalendar() {
|
||||
navigateTo(`/calendar?year=${year}&month=${month}`);
|
||||
}
|
||||
|
||||
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
|
||||
const summary = (data?.summary ?? {}) as TrackerSummary;
|
||||
const bankTracking = data?.bank_tracking;
|
||||
|
|
@ -675,7 +697,7 @@ export default function TrackerPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 tracker-print-hide">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pinUpcoming ? 'default' : 'outline'}
|
||||
|
|
@ -713,6 +735,45 @@ export default function TrackerPage() {
|
|||
<span className="hidden sm:inline">Add Bill</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={goToCalendar}
|
||||
className="h-9 gap-1.5 px-3 tracker-print-hide"
|
||||
aria-label="Open calendar for this month"
|
||||
title="View this month on the calendar"
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Calendar</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 gap-1.5 px-3 tracker-print-hide"
|
||||
aria-label="Export or print this month"
|
||||
title="Export or print this month"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleExportMonthCsv}>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => window.print()}>
|
||||
<Printer className="mr-2 h-4 w-4" />
|
||||
Print
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
|
|
@ -751,6 +812,7 @@ export default function TrackerPage() {
|
|||
{showSearchSort && (
|
||||
<SearchFilterPanel
|
||||
title="Search & sort"
|
||||
className="tracker-print-hide"
|
||||
collapsed={searchPanelCollapsed}
|
||||
onCollapsedChange={setSearchPanelCollapsed}
|
||||
hasFilters={hasFilters}
|
||||
|
|
|
|||
Loading…
Reference in New Issue