222 lines
10 KiB
JavaScript
222 lines
10 KiB
JavaScript
import { writeFileSync } from "fs";
|
|
|
|
writeFileSync("app/exports/page.tsx", `"use client";
|
|
|
|
import { useState } from "react";
|
|
import { AppShell } from "../../components/app-shell";
|
|
import { apiFetch } from "../../lib/api";
|
|
|
|
type ApiResponse<T> = {
|
|
data: T;
|
|
meta: { timestamp: string; version: "v1" };
|
|
error: null | { message: string; code?: string };
|
|
};
|
|
|
|
type ExportData = { status: string; csv?: string; rowCount?: number };
|
|
type SheetsData = { spreadsheetUrl?: string; spreadsheetId?: string; rowCount?: number };
|
|
|
|
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 [filters, setFilters] = useState({
|
|
startDate: "",
|
|
endDate: "",
|
|
minAmount: "",
|
|
maxAmount: "",
|
|
category: "",
|
|
source: "",
|
|
includeHidden: 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 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;
|
|
}
|
|
if (res.data.spreadsheetUrl) {
|
|
setSheetsUrl(res.data.spreadsheetUrl);
|
|
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">
|
|
{/* Date preset selector */}
|
|
<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 actions */}
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{/* CSV export */}
|
|
<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 export */}
|
|
<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>
|
|
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">Creates a new Google Sheet with a dated tab. Requires Google OAuth to be configured.</p>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|
|
`);
|
|
|
|
console.log("✅ exports/page.tsx written with CSV download + Google Sheets export");
|