381 lines
18 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ExportData = { status: string; csv?: string; rowCount?: number };
type SignedExportData = { status: string; downloadUrl?: string; expiresAt?: string; expiresInSeconds?: number; singleUse?: boolean };
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
export default function ExportsPage() {
const [csvStatus, setCsvStatus] = useState("");
const [jsonStatus, setJsonStatus] = useState("");
const [xlsxStatus, setXlsxStatus] = useState("");
const [pdfStatus, setPdfStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom");
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false,
});
useEffect(() => {
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
});
}, []);
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
if (preset === "this_month") {
start = new Date(end.getFullYear(), end.getMonth(), 1);
} else if (preset === "last_month") {
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
end.setDate(0);
} else if (preset === "last_6_months") {
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
} else if (preset === "last_year") {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const buildParams = () => {
const params = new URLSearchParams();
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.includeHidden) params.set("include_hidden", "true");
return params;
};
const downloadSignedFile = (url: string) => {
const a = document.createElement("a");
a.href = url;
a.rel = "noopener";
a.click();
};
const requestSignedDownload = async (format: "csv" | "json" | "xlsx" | "pdf") => {
const params = buildParams();
const query = params.toString() ? `?${params.toString()}` : "";
return apiFetch<SignedExportData>(`/api/exports/${format}${query}`);
};
const onExportCsv = async () => {
setCsvStatus("Generating export...");
const res = await requestSignedDownload("csv");
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setCsvStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setCsvStatus("Export link created.");
}
};
const onExportJson = async () => {
setJsonStatus("Generating JSON export...");
const res = await requestSignedDownload("json");
if (res.error) { setJsonStatus(res.error.message ?? "JSON export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setJsonStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setJsonStatus("JSON export link created.");
}
};
const onExportXlsx = async () => {
setXlsxStatus("Generating XLSX export...");
const res = await requestSignedDownload("xlsx");
if (res.error) { setXlsxStatus(res.error.message ?? "XLSX export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setXlsxStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setXlsxStatus("XLSX export link created.");
}
};
const onExportPdf = async () => {
setPdfStatus("Generating PDF export...");
const res = await requestSignedDownload("pdf");
if (res.error) { setPdfStatus(res.error.message ?? "PDF export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setPdfStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setPdfStatus("PDF export link created.");
}
};
const onConnectGoogle = async () => {
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
if (res.error) {
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
return;
}
if (res.data?.authUrl) {
window.location.href = res.data.authUrl;
}
};
const onDisconnectGoogle = async () => {
setDisconnecting(true);
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
setDisconnecting(false);
if (!res.error) {
setGoogleStatus({ connected: false });
setSheetsStatus("Google account disconnected.");
setSheetsUrl(null);
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
if (url) {
setSheetsUrl(url);
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Exports" subtitle="Generate CSV, JSON, XLSX, PDF, or Google Sheets exports.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Filters */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="custom">Custom</option>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Category contains</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Min amount ($)</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount ($)</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden transactions
</label>
</div>
</div>
<div className="mt-6" />
{/* Export cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{/* CSV */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download CSV</p>
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
</div>
</div>
<button
onClick={onExportCsv}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Export CSV
</button>
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div>
{/* JSON */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l-3 3 3 3m8-6l3 3-3 3M13 5l-2 14" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download JSON</p>
<p className="mt-1 text-xs text-muted-foreground">Structured transaction export for custom tools.</p>
</div>
</div>
<button
onClick={onExportJson}
className="mt-4 w-full rounded-lg border border-amber-500/30 bg-amber-500/10 py-2.5 px-4 text-sm font-bold text-amber-500 hover:bg-amber-500/20 transition-all"
>
Export JSON
</button>
{jsonStatus && <p className="mt-2 text-xs text-muted-foreground">{jsonStatus}</p>}
</div>
{/* XLSX */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-6m3 6V7m3 10v-4m4 8H5a2 2 0 01-2-2V5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download XLSX</p>
<p className="mt-1 text-xs text-muted-foreground">Excel workbook with filtered transaction data.</p>
</div>
</div>
<button
onClick={onExportXlsx}
className="mt-4 w-full rounded-lg border border-blue-500/30 bg-blue-500/10 py-2.5 px-4 text-sm font-bold text-blue-500 hover:bg-blue-500/20 transition-all"
>
Export XLSX
</button>
{xlsxStatus && <p className="mt-2 text-xs text-muted-foreground">{xlsxStatus}</p>}
</div>
{/* PDF */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-red-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9l-6-6H7a2 2 0 00-2 2v14a2 2 0 002 2zm2-8h6m-6 4h6m-6-8h2" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download PDF</p>
<p className="mt-1 text-xs text-muted-foreground">Printable ledger snapshot for audit packets.</p>
</div>
</div>
<button
onClick={onExportPdf}
className="mt-4 w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
>
Export PDF
</button>
{pdfStatus && <p className="mt-2 text-xs text-muted-foreground">{pdfStatus}</p>}
</div>
{/* Google Sheets */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
{googleStatus?.connected ? (
<div className="flex items-center gap-1 mt-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
)}
</div>
</div>
{googleStatus?.connected ? (
<>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
<button
onClick={onDisconnectGoogle}
disabled={disconnecting}
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
>
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
</button>
</>
) : (
<button
onClick={onConnectGoogle}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
>
Connect Google Account
</button>
)}
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div>
</div>
</AppShell>
);
}