766 lines
29 KiB
TypeScript
766 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { AppShell } from "../../components/app-shell";
|
|
|
|
type ApiResponse<T> = {
|
|
data: T;
|
|
meta: { timestamp: string; version: "v1" };
|
|
error: null | { message: string; code?: string };
|
|
};
|
|
|
|
type TransactionRow = {
|
|
id: string;
|
|
name: string;
|
|
amount: string;
|
|
category: string;
|
|
note: string;
|
|
status: string;
|
|
hidden?: boolean;
|
|
date: string;
|
|
};
|
|
|
|
type Account = {
|
|
id: string;
|
|
institutionName: string;
|
|
accountType: string;
|
|
mask?: string | null;
|
|
};
|
|
|
|
export default function TransactionsPage() {
|
|
const [rows, setRows] = useState<TransactionRow[]>([]);
|
|
const [status, setStatus] = useState("Loading transactions...");
|
|
const [summary, setSummary] = useState<{
|
|
total: string;
|
|
count: number;
|
|
income?: string;
|
|
expense?: string;
|
|
net?: string;
|
|
} | null>(null);
|
|
const [datePreset, setDatePreset] = useState("this_month");
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [userId, setUserId] = useState<string | null>(null);
|
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
const [autoSync, setAutoSync] = useState(true);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [showManual, setShowManual] = useState(false);
|
|
const [manualForm, setManualForm] = useState({
|
|
accountId: "",
|
|
date: new Date().toISOString().slice(0, 10),
|
|
description: "",
|
|
amount: "",
|
|
category: "",
|
|
note: ""
|
|
});
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editForm, setEditForm] = useState({
|
|
category: "",
|
|
note: "",
|
|
hidden: false
|
|
});
|
|
const [filters, setFilters] = useState({
|
|
startDate: "",
|
|
endDate: "",
|
|
minAmount: "",
|
|
maxAmount: "",
|
|
category: "",
|
|
source: "",
|
|
search: "",
|
|
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 format = (value: Date) => value.toISOString().slice(0, 10);
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
startDate: format(start),
|
|
endDate: format(end)
|
|
}));
|
|
};
|
|
|
|
const buildQuery = () => {
|
|
const params = new URLSearchParams();
|
|
if (userId) {
|
|
params.set("user_id", userId);
|
|
}
|
|
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.search) {
|
|
params.set("search", filters.search);
|
|
}
|
|
if (filters.includeHidden) {
|
|
params.set("include_hidden", "true");
|
|
}
|
|
return params.toString() ? `?${params.toString()}` : "";
|
|
};
|
|
|
|
const load = async () => {
|
|
const query = buildQuery();
|
|
try {
|
|
const res = await fetch(`/api/transactions${query}`);
|
|
const payload = (await res.json()) as ApiResponse<TransactionRow[]>;
|
|
if (!res.ok || payload.error) {
|
|
setStatus(payload.error?.message ?? "Unable to load transactions.");
|
|
return;
|
|
}
|
|
setRows(payload.data);
|
|
console.log(payload.data);
|
|
setStatus(payload.data.length ? "" : "No transactions yet.");
|
|
} catch {
|
|
setStatus("Unable to load transactions.");
|
|
}
|
|
};
|
|
|
|
const loadAccounts = async () => {
|
|
const userId = localStorage.getItem("ledgerone_user_id");
|
|
if (!userId) {
|
|
return;
|
|
}
|
|
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
|
|
if (!res.ok) {
|
|
return;
|
|
}
|
|
const payload = (await res.json()) as ApiResponse<Account[]>;
|
|
if (!payload.error) {
|
|
setAccounts(payload.data);
|
|
}
|
|
};
|
|
|
|
const loadSummary = async () => {
|
|
const query = buildQuery();
|
|
const res = await fetch(`/api/transactions/summary${query}`);
|
|
if (!res.ok) {
|
|
return;
|
|
}
|
|
const payload = (await res.json()) as ApiResponse<{ total: string; count: number }>;
|
|
if (!payload.error) {
|
|
setSummary(payload.data);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setUserId(localStorage.getItem("ledgerone_user_id"));
|
|
applyPreset("this_month");
|
|
load();
|
|
loadSummary();
|
|
loadAccounts();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!autoSync) {
|
|
return;
|
|
}
|
|
const id = setInterval(() => {
|
|
onSync();
|
|
}, 5 * 60 * 1000);
|
|
return () => clearInterval(id);
|
|
}, [autoSync, filters.startDate, filters.endDate]);
|
|
|
|
const onSync = async () => {
|
|
const userId = localStorage.getItem("ledgerone_user_id");
|
|
if (!userId) {
|
|
setStatus("Missing user id.");
|
|
return;
|
|
}
|
|
if (isSyncing) {
|
|
return;
|
|
}
|
|
setIsSyncing(true);
|
|
setStatus("Syncing transactions...");
|
|
const res = await fetch("/api/transactions/sync", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
userId,
|
|
startDate: filters.startDate || undefined,
|
|
endDate: filters.endDate || undefined
|
|
})
|
|
});
|
|
const payload = await res.json();
|
|
if (!res.ok || payload.error) {
|
|
setStatus(payload.error?.message ?? "Sync failed.");
|
|
setIsSyncing(false);
|
|
return;
|
|
}
|
|
setStatus("Sync complete.");
|
|
await load();
|
|
await loadSummary();
|
|
setIsSyncing(false);
|
|
};
|
|
|
|
const formatAmount = (value: string) => {
|
|
const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, ""));
|
|
if (Number.isNaN(numeric)) {
|
|
return { display: value, tone: "text-foreground" };
|
|
}
|
|
return {
|
|
display: numeric < 0 ? `-$${Math.abs(numeric).toFixed(2)}` : `$${numeric.toFixed(2)}`,
|
|
tone: numeric < 0 ? "text-foreground" : "text-primary font-bold"
|
|
};
|
|
};
|
|
|
|
const onManualCreate = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const userId = localStorage.getItem("ledgerone_user_id");
|
|
if (!userId) {
|
|
setStatus("Missing user id.");
|
|
return;
|
|
}
|
|
const amount = Number.parseFloat(manualForm.amount);
|
|
if (Number.isNaN(amount)) {
|
|
setStatus("Invalid amount.");
|
|
return;
|
|
}
|
|
const payload = {
|
|
userId,
|
|
accountId: manualForm.accountId || undefined,
|
|
date: manualForm.date,
|
|
description: manualForm.description,
|
|
amount,
|
|
category: manualForm.category || undefined,
|
|
note: manualForm.note || undefined
|
|
};
|
|
setStatus("Saving manual transaction...");
|
|
const res = await fetch("/api/transactions/manual", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = (await res.json()) as ApiResponse<unknown>;
|
|
if (!res.ok || data.error) {
|
|
setStatus(data.error?.message ?? "Unable to save transaction.");
|
|
return;
|
|
}
|
|
setManualForm((prev) => ({
|
|
...prev,
|
|
description: "",
|
|
amount: "",
|
|
category: "",
|
|
note: ""
|
|
}));
|
|
setShowManual(false);
|
|
setStatus("Manual transaction saved.");
|
|
await load();
|
|
await loadSummary();
|
|
};
|
|
|
|
const startEdit = (row: TransactionRow) => {
|
|
setEditingId(row.id);
|
|
setEditForm({
|
|
category: row.category === "Uncategorized" ? "" : row.category,
|
|
note: row.note,
|
|
hidden: Boolean(row.hidden)
|
|
});
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingId) {
|
|
return;
|
|
}
|
|
setStatus("Saving edits...");
|
|
const res = await fetch(`/api/transactions/${editingId}/derived`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
userCategory: editForm.category || undefined,
|
|
userNotes: editForm.note || undefined,
|
|
isHidden: editForm.hidden
|
|
})
|
|
});
|
|
const data = (await res.json()) as ApiResponse<unknown>;
|
|
if (!res.ok || data.error) {
|
|
setStatus(data.error?.message ?? "Unable to save edits.");
|
|
return;
|
|
}
|
|
setEditingId(null);
|
|
setStatus("Transaction updated.");
|
|
await load();
|
|
await loadSummary();
|
|
};
|
|
|
|
return (
|
|
<AppShell title="Transactions" subtitle="Raw transactions from the last 30 days.">
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mb-6">
|
|
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border shadow-sm">
|
|
<span className="h-2 w-2 rounded-full bg-primary" />
|
|
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border shadow-sm">
|
|
<span className="h-2 w-2 rounded-full bg-primary" />
|
|
All accounts
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onSync}
|
|
className="ml-auto px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors shadow-sm"
|
|
>
|
|
{isSyncing ? "Syncing..." : "Sync transactions"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAutoSync((prev) => !prev)}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
Auto sync {autoSync ? "On" : "Off"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowManual((prev) => !prev)}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
{showManual ? "Hide manual" : "Add manual"}
|
|
</button>
|
|
<Link
|
|
href={`/exports${buildQuery()}`}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
Export CSV
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowFilters((prev) => !prev)}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
{showFilters ? "Hide filters" : "Show filters"}
|
|
</button>
|
|
</div>
|
|
|
|
{showFilters ? (
|
|
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
|
<div className="grid gap-4 md:grid-cols-[1fr_1fr_1fr]">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Date range
|
|
</label>
|
|
<select
|
|
value={datePreset}
|
|
onChange={(event) => applyPreset(event.target.value)}
|
|
className="mt-2 w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
>
|
|
<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>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Start date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={filters.startDate}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, startDate: event.target.value }))
|
|
}
|
|
className="mt-2 w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
disabled={datePreset !== "custom"}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
End date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={filters.endDate}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, endDate: event.target.value }))
|
|
}
|
|
className="mt-2 w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
disabled={datePreset !== "custom"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
|
<input
|
|
type="text"
|
|
value={filters.search}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, search: event.target.value }))
|
|
}
|
|
placeholder="Search description"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={filters.category}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, category: event.target.value }))
|
|
}
|
|
placeholder="Category contains"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={filters.minAmount}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, minAmount: event.target.value }))
|
|
}
|
|
placeholder="Min amount"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={filters.maxAmount}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, maxAmount: event.target.value }))
|
|
}
|
|
placeholder="Max amount"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
|
<input
|
|
type="text"
|
|
value={filters.source}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, source: event.target.value }))
|
|
}
|
|
placeholder="Source contains"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={filters.includeHidden}
|
|
onChange={(event) =>
|
|
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
|
|
}
|
|
className="rounded border-border text-primary focus:ring-primary"
|
|
/>
|
|
Include hidden
|
|
</label>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
load();
|
|
loadSummary();
|
|
}}
|
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
|
|
>
|
|
Apply
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDatePreset("this_month");
|
|
setFilters({
|
|
startDate: "",
|
|
endDate: "",
|
|
minAmount: "",
|
|
maxAmount: "",
|
|
category: "",
|
|
source: "",
|
|
search: "",
|
|
includeHidden: false
|
|
});
|
|
applyPreset("this_month");
|
|
load();
|
|
loadSummary();
|
|
}}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{showManual ? (
|
|
<form className="mb-6 glass-panel rounded-xl p-5 shadow-sm" onSubmit={onManualCreate}>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Manual transaction
|
|
</p>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_1fr_1fr]">
|
|
<div>
|
|
<label className="block text-xs font-medium text-foreground">
|
|
Account
|
|
</label>
|
|
<select
|
|
value={manualForm.accountId}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, accountId: event.target.value }))
|
|
}
|
|
className="mt-1 block w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
>
|
|
<option value="">Default account</option>
|
|
{accounts.map((account) => (
|
|
<option key={account.id} value={account.id}>
|
|
{account.institutionName} {account.mask ? `• ${account.mask}` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-foreground">
|
|
Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={manualForm.date}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, date: event.target.value }))
|
|
}
|
|
className="mt-1 block w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-foreground">
|
|
Amount
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={manualForm.amount}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, amount: event.target.value }))
|
|
}
|
|
placeholder="0.00"
|
|
className="mt-1 block w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-[2fr_1fr_1fr]">
|
|
<input
|
|
type="text"
|
|
value={manualForm.description}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, description: event.target.value }))
|
|
}
|
|
placeholder="Description"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
required
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.category}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, category: event.target.value }))
|
|
}
|
|
placeholder="Category (optional)"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.note}
|
|
onChange={(event) =>
|
|
setManualForm((prev) => ({ ...prev, note: event.target.value }))
|
|
}
|
|
placeholder="Note (optional)"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
|
|
>
|
|
Save manual transaction
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowManual(false)}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : null}
|
|
|
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
|
<div className="glass-panel rounded-xl p-5 shadow-sm">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">30-day total</p>
|
|
<p className="mt-2 text-2xl font-bold text-foreground">
|
|
{summary ? `$${summary.total}` : "$0.00"}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{summary ? `${summary.count} transactions` : "No data yet."}
|
|
</p>
|
|
</div>
|
|
<div className="glass-panel rounded-xl p-5 shadow-sm">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Income vs expense</p>
|
|
<div className="mt-2 flex items-center justify-between text-sm">
|
|
<span className="text-primary font-medium">Income</span>
|
|
<span className="font-medium text-foreground">{summary?.income ? `$${summary.income}` : "$0.00"}</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground font-medium">Expense</span>
|
|
<span className="font-medium text-foreground">{summary?.expense ? `$${summary.expense}` : "$0.00"}</span>
|
|
</div>
|
|
<div className="mt-2 pt-2 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Net</span>
|
|
<span className="font-medium text-foreground">{summary?.net ? `$${summary.net}` : "$0.00"}</span>
|
|
</div>
|
|
</div>
|
|
<div className="glass-panel rounded-xl p-5 shadow-sm">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Auto sync</p>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{autoSync ? "Running every 5 minutes." : "Paused."}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAutoSync((prev) => !prev)}
|
|
className="mt-3 px-3 py-1.5 rounded-md bg-background border border-border text-foreground text-xs font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
{autoSync ? "Pause auto sync" : "Enable auto sync"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 glass-panel rounded-xl shadow-sm overflow-hidden">
|
|
<div className="p-6 border-b border-border">
|
|
{status ? <p className="text-sm text-muted-foreground">{status}</p> : null}
|
|
</div>
|
|
|
|
{editingId ? (
|
|
<div className="p-6 bg-secondary/30 border-b border-border">
|
|
<p className="text-sm font-semibold text-foreground">Edit transaction</p>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
|
<input
|
|
type="text"
|
|
value={editForm.category}
|
|
onChange={(event) =>
|
|
setEditForm((prev) => ({ ...prev, category: event.target.value }))
|
|
}
|
|
placeholder="Category"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={editForm.note}
|
|
onChange={(event) =>
|
|
setEditForm((prev) => ({ ...prev, note: event.target.value }))
|
|
}
|
|
placeholder="Note"
|
|
className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
|
|
/>
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={editForm.hidden}
|
|
onChange={(event) =>
|
|
setEditForm((prev) => ({ ...prev, hidden: event.target.checked }))
|
|
}
|
|
className="rounded border-border text-primary focus:ring-primary"
|
|
/>
|
|
Hide transaction
|
|
</label>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={saveEdit}
|
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
|
|
>
|
|
Save edits
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingId(null)}
|
|
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{rows.length ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-border">
|
|
<thead className="bg-secondary/30">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Date</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Description</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Amount</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Category</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-background divide-y divide-border">
|
|
{rows.map((row) => (
|
|
<tr key={row.id} className="hover:bg-secondary/30 transition-colors">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{row.date}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="text-sm font-medium text-foreground">{row.name}</div>
|
|
<div className="text-xs text-muted-foreground">{row.note || "No notes"}</div>
|
|
{row.hidden ? (
|
|
<div className="mt-1 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
|
Hidden
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<span className={formatAmount(row.amount).tone}>
|
|
{formatAmount(row.amount).display}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{row.category}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-foreground">
|
|
{row.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
|
<button
|
|
type="button"
|
|
onClick={() => startEdit(row)}
|
|
className="text-primary hover:text-primary/80 font-medium"
|
|
>
|
|
Edit
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|