2026-03-18 13:02:58 -07:00

270 lines
12 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 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 [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 onExportCsv = async () => {
setCsvStatus("Generating export...");
const params = buildParams();
const query = params.toString() ? `?${params.toString()}` : "";
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
if (res.data?.csv) {
const blob = new Blob([res.data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
} else {
setCsvStatus("Export ready.");
}
};
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 datasets or export to Google Sheets.">
<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">
{/* 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>
{/* 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>
);
}