270 lines
12 KiB
TypeScript
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="border-t border-border" />
|
|
|
|
{/* 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>
|
|
);
|
|
}
|