BillTracker/client/components/data/DownloadMyDataSection.jsx

173 lines
8.2 KiB
React
Raw Permalink Normal View History

import React, { useState } from 'react';
import { toast } from 'sonner';
import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { SectionCard } 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, fallbackName) {
const res = await fetch(endpoint, { credentials: 'include' });
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
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 ? 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 }) {
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
try {
await downloadFile(endpoint, filename);
toast.success(`${title} downloaded.`);
} catch (err) {
toast.error(err.message || 'Download failed.');
} finally {
setLoading(false);
}
};
return (
<div className="flex items-start justify-between gap-6 px-6 py-5">
<div className="flex min-w-0 flex-1 items-start gap-4">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="mb-0.5 text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="shrink-0 pt-0.5">
<Button size="sm" variant="outline" disabled={loading} onClick={handleDownload}>
{loading ? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Downloading</>
: <><Download className="mr-1.5 h-3.5 w-3.5" />Download</>}
</Button>
</div>
</div>
);
}
// Filtered payments export: current year by default, or a custom date range, as CSV or JSON.
function PaymentsExport() {
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [format, setFormat] = useState('csv');
const [loading, setLoading] = useState(false);
const handle = async () => {
if ((from && !to) || (!from && to)) { toast.error('Enter both From and To dates, or leave both empty.'); return; }
if (from && to && from > to) { toast.error('“From” must be on or before “To”.'); return; }
const params = new URLSearchParams({ format });
if (from && to) { params.set('from', from); params.set('to', to); }
setLoading(true);
try {
await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`);
toast.success('Payments exported.');
} catch (err) {
toast.error(err.message || 'Export failed.');
} finally {
setLoading(false);
}
};
return (
<div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<p className="text-sm font-medium">Payments export</p>
<p className="mt-0.5 text-xs text-muted-foreground">Just your payment records filter by date and choose a format. Leave dates empty for the current year.</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div>
<Label htmlFor="pe-from" className="text-xs">From</Label>
<Input id="pe-from" type="date" value={from} onChange={e => setFrom(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
</div>
<div>
<Label htmlFor="pe-to" className="text-xs">To</Label>
<Input id="pe-to" type="date" value={to} onChange={e => setTo(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
</div>
<div>
<span className="block text-xs text-muted-foreground">Format</span>
<div className="mt-1 inline-flex rounded-lg border border-border/60 p-0.5" role="group" aria-label="Export format">
{['csv', 'json'].map(f => (
<button
key={f}
type="button"
aria-pressed={format === f}
onClick={() => setFormat(f)}
className={cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
format === f ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{f.toUpperCase()}
</button>
))}
</div>
</div>
<Button size="sm" className="h-9 gap-1.5" disabled={loading} onClick={handle}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
Export
</Button>
</div>
</div>
</div>
);
}
export default function DownloadMyDataSection({ cardProps = {} }) {
return (
<SectionCard
title="Download My Data"
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
{...cardProps}
>
<ExportCard icon={Database} title="SQLite backup"
description="A portable SQLite database with all of your bill tracker data — the format used to restore a backup."
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
<ExportCard icon={FileSpreadsheet} title="Excel databook"
description="An Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
<ExportCard icon={FileJson} title="JSON export"
description="Your full data as portable JSON — handy for scripting, backups, or moving between tools."
filename="bill-tracker-user-export.json" endpoint="/api/export/user-json" />
<PaymentsExport />
<div className="mx-6 mt-2 flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-6 py-3 dark:border-amber-800/40 dark:bg-amber-950/30">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
</div>
<div className="grid grid-cols-1 gap-3 px-6 py-5 sm:grid-cols-2">
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
<p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's included</p>
<ul className="space-y-1.5">
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />{i}
</li>
))}
</ul>
</div>
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
<p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's not included</p>
<ul className="space-y-1.5">
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />{i}
</li>
))}
</ul>
</div>
</div>
</SectionCard>
);
}