Wire browser-safe finance presentation UI
This commit is contained in:
parent
1d7007637d
commit
f0e6405b11
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts");
|
||||
return proxyRequest(req, "view/accounts");
|
||||
}
|
||||
|
||||
6
app/api/bill-pay/bills/[id]/pay/route.ts
Normal file
6
app/api/bill-pay/bills/[id]/pay/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `bill-pay/bills/${params.id}/pay`);
|
||||
}
|
||||
6
app/api/bill-pay/bills/[id]/route.ts
Normal file
6
app/api/bill-pay/bills/[id]/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `bill-pay/bills/${params.id}`);
|
||||
}
|
||||
10
app/api/bill-pay/bills/route.ts
Normal file
10
app/api/bill-pay/bills/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/bills");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/bills");
|
||||
}
|
||||
10
app/api/bill-pay/payees/route.ts
Normal file
10
app/api/bill-pay/payees/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/payees");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/payees");
|
||||
}
|
||||
6
app/api/bill-pay/summary/route.ts
Normal file
6
app/api/bill-pay/summary/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/summary");
|
||||
}
|
||||
10
app/api/credit-score/entries/route.ts
Normal file
10
app/api/credit-score/entries/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/entries");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/entries");
|
||||
}
|
||||
6
app/api/credit-score/summary/route.ts
Normal file
6
app/api/credit-score/summary/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/summary");
|
||||
}
|
||||
6
app/api/notifications/[id]/read/route.ts
Normal file
6
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `notifications/${params.id}/read`);
|
||||
}
|
||||
10
app/api/notifications/preferences/route.ts
Normal file
10
app/api/notifications/preferences/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/preferences");
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/preferences");
|
||||
}
|
||||
6
app/api/notifications/push-subscriptions/route.ts
Normal file
6
app/api/notifications/push-subscriptions/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/push-subscriptions");
|
||||
}
|
||||
6
app/api/notifications/read-all/route.ts
Normal file
6
app/api/notifications/read-all/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/read-all");
|
||||
}
|
||||
6
app/api/notifications/route.ts
Normal file
6
app/api/notifications/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications");
|
||||
}
|
||||
6
app/api/notifications/test/route.ts
Normal file
6
app/api/notifications/test/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/test");
|
||||
}
|
||||
6
app/api/notifications/vapid-public-key/route.ts
Normal file
6
app/api/notifications/vapid-public-key/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/vapid-public-key");
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions");
|
||||
return proxyRequest(req, "view/transactions");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
6
app/api/view/accounts/route.ts
Normal file
6
app/api/view/accounts/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/accounts");
|
||||
}
|
||||
6
app/api/view/transactions/route.ts
Normal file
6
app/api/view/transactions/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/transactions");
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { usePlaidLink } from "react-plaid-link";
|
||||
|
||||
type Account = {
|
||||
id: string;
|
||||
viewRef: string;
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string | null;
|
||||
@ -43,7 +43,7 @@ declare global {
|
||||
export default function ConnectPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
const [updateAccountId, setUpdateAccountId] = useState<string | null>(null);
|
||||
const [updateAccountRef, setUpdateAccountRef] = useState<string | null>(null);
|
||||
const [linkMode, setLinkMode] = useState<"connect" | "update">("connect");
|
||||
const [pendingOpen, setPendingOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
@ -120,13 +120,13 @@ export default function ConnectPage() {
|
||||
|
||||
const onSuccess = useCallback(
|
||||
async (publicToken: string | null) => {
|
||||
if (linkMode === "update" && updateAccountId) {
|
||||
if (linkMode === "update" && updateAccountRef) {
|
||||
setStatus("Finishing bank reconnection...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/repair-complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: updateAccountId })
|
||||
body: JSON.stringify({ accountId: updateAccountRef })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
@ -134,7 +134,7 @@ export default function ConnectPage() {
|
||||
return;
|
||||
}
|
||||
setStatus("Bank connection repaired.");
|
||||
setUpdateAccountId(null);
|
||||
setUpdateAccountRef(null);
|
||||
setLinkMode("connect");
|
||||
setLinkToken(null);
|
||||
await loadAccounts();
|
||||
@ -170,7 +170,7 @@ export default function ConnectPage() {
|
||||
setStatus("Unable to exchange token.");
|
||||
}
|
||||
},
|
||||
[createLinkToken, linkMode, loadAccounts, updateAccountId]
|
||||
[createLinkToken, linkMode, loadAccounts, updateAccountRef]
|
||||
);
|
||||
|
||||
const { open, ready } = usePlaidLink({
|
||||
@ -218,13 +218,13 @@ export default function ConnectPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const startUpdateMode = async (accountId: string) => {
|
||||
const startUpdateMode = async (accountRef: string) => {
|
||||
setStatus("Requesting Plaid update-mode link token...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/update-link-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId })
|
||||
body: JSON.stringify({ accountId: accountRef })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
@ -236,7 +236,7 @@ export default function ConnectPage() {
|
||||
setStatus("Unable to create update-mode link token.");
|
||||
return;
|
||||
}
|
||||
setUpdateAccountId(accountId);
|
||||
setUpdateAccountRef(accountRef);
|
||||
setLinkMode("update");
|
||||
setLinkToken(token);
|
||||
setStatus("Reconnect token ready. Opening Plaid...");
|
||||
@ -351,7 +351,7 @@ export default function ConnectPage() {
|
||||
</p>
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
key={account.viewRef}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
@ -374,7 +374,7 @@ export default function ConnectPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
onClick={() => startUpdateMode(account.id)}
|
||||
onClick={() => startUpdateMode(account.viewRef)}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
|
||||
@ -33,11 +33,10 @@ type MerchantInsight = {
|
||||
};
|
||||
|
||||
type TxRow = {
|
||||
id: string;
|
||||
viewRef: string;
|
||||
date: string;
|
||||
description: string;
|
||||
category?: string | null;
|
||||
accountId?: string | null;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
@ -507,14 +506,14 @@ export default function AppHomePage() {
|
||||
apiFetch<Summary>("/api/transactions/summary"),
|
||||
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
||||
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
||||
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
|
||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
|
||||
apiFetch<{ accounts: { viewRef: string }[]; total: number }>("/api/view/accounts?limit=5"),
|
||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/view/transactions?limit=5"),
|
||||
])
|
||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
||||
if (!summaryRes.error) setSummary(summaryRes.data);
|
||||
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data?.total ?? accountsRes.data?.accounts?.length ?? 0);
|
||||
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
@ -737,7 +736,7 @@ export default function AppHomePage() {
|
||||
const fmtAmt = formatCurrency(amt);
|
||||
const isIncome = amt >= 0;
|
||||
return (
|
||||
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<tr key={tx.viewRef} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
|
||||
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
||||
<td className="py-3">
|
||||
|
||||
303
app/bills/page.tsx
Normal file
303
app/bills/page.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Payee = {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
accountNumberLast4?: string | null;
|
||||
};
|
||||
|
||||
type Bill = {
|
||||
id: string;
|
||||
payeeId?: string | null;
|
||||
name: string;
|
||||
amount: string | number;
|
||||
currency: string;
|
||||
dueDate: string;
|
||||
status: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
|
||||
computedStatus: string;
|
||||
recurrence: string;
|
||||
autopay: boolean;
|
||||
reminderDays: number;
|
||||
paidAt?: string | null;
|
||||
payee?: Payee | null;
|
||||
};
|
||||
|
||||
type BillSummary = {
|
||||
activeCount: number;
|
||||
upcomingCount: number;
|
||||
overdueCount: number;
|
||||
totalDueNext30: string;
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function BillsPage() {
|
||||
const [payees, setPayees] = useState<Payee[]>([]);
|
||||
const [bills, setBills] = useState<Bill[]>([]);
|
||||
const [summary, setSummary] = useState<BillSummary | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [payeeForm, setPayeeForm] = useState({ name: "", category: "", accountNumberLast4: "" });
|
||||
const [billForm, setBillForm] = useState({
|
||||
payeeId: "",
|
||||
name: "",
|
||||
amount: "",
|
||||
dueDate: today,
|
||||
status: "pending",
|
||||
recurrence: "none",
|
||||
autopay: false,
|
||||
reminderDays: "3",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
if (filter === "all") return bills;
|
||||
if (filter === "overdue") return bills.filter((bill) => bill.computedStatus === "overdue");
|
||||
return bills.filter((bill) => bill.status === filter);
|
||||
}, [bills, filter]);
|
||||
|
||||
const money = (value: string | number, currency = "USD") =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value ?? 0));
|
||||
|
||||
const load = async () => {
|
||||
const [payeesRes, billsRes, summaryRes] = await Promise.all([
|
||||
apiFetch<Payee[]>("/api/bill-pay/payees"),
|
||||
apiFetch<Bill[]>("/api/bill-pay/bills"),
|
||||
apiFetch<BillSummary>("/api/bill-pay/summary"),
|
||||
]);
|
||||
if (!payeesRes.error) setPayees(payeesRes.data ?? []);
|
||||
if (!billsRes.error) setBills(billsRes.data ?? []);
|
||||
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => setStatus("Unable to load bills."));
|
||||
}, []);
|
||||
|
||||
const createPayee = async () => {
|
||||
if (!payeeForm.name.trim()) {
|
||||
setStatus("Payee name is required.");
|
||||
return;
|
||||
}
|
||||
const res = await apiFetch<Payee>("/api/bill-pay/payees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payeeForm),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to create payee.");
|
||||
return;
|
||||
}
|
||||
setPayeeForm({ name: "", category: "", accountNumberLast4: "" });
|
||||
setStatus("Payee created.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const createBill = async () => {
|
||||
if (!billForm.name.trim() || !billForm.amount || !billForm.dueDate) {
|
||||
setStatus("Bill name, amount, and due date are required.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...billForm,
|
||||
payeeId: billForm.payeeId || undefined,
|
||||
amount: Number(billForm.amount),
|
||||
reminderDays: Number(billForm.reminderDays || 3),
|
||||
};
|
||||
const res = await apiFetch<Bill>("/api/bill-pay/bills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to create bill.");
|
||||
return;
|
||||
}
|
||||
setBillForm({ payeeId: "", name: "", amount: "", dueDate: today, status: "pending", recurrence: "none", autopay: false, reminderDays: "3", notes: "" });
|
||||
setStatus("Bill created.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const markPaid = async (bill: Bill) => {
|
||||
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/pay`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
amount: Number(bill.amount),
|
||||
paidAt: new Date().toISOString(),
|
||||
method: bill.autopay ? "autopay" : "manual",
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to mark bill paid.");
|
||||
return;
|
||||
}
|
||||
setStatus(`${bill.name} marked paid.`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const updateStatus = async (bill: Bill, nextStatus: Bill["status"]) => {
|
||||
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to update bill.");
|
||||
return;
|
||||
}
|
||||
setStatus("Bill updated.");
|
||||
await load();
|
||||
};
|
||||
|
||||
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="Bills" subtitle="Track payees, due dates, reminders, and recorded payments.">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Active bills</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.activeCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Due next 30 days</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.upcomingCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.overdueCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Total due next 30 days</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{money(summary?.totalDueNext30 ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Payees</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Name</label>
|
||||
<input value={payeeForm.name} onChange={(event) => setPayeeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Category</label>
|
||||
<input value={payeeForm.category} onChange={(event) => setPayeeForm((prev) => ({ ...prev, category: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Account last 4</label>
|
||||
<input maxLength={4} value={payeeForm.accountNumberLast4} onChange={(event) => setPayeeForm((prev) => ({ ...prev, accountNumberLast4: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<button onClick={createPayee} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Payee</button>
|
||||
</div>
|
||||
<div className="mt-6 divide-y divide-border">
|
||||
{payees.length === 0 && <p className="py-4 text-sm text-muted-foreground">No payees yet.</p>}
|
||||
{payees.map((payee) => (
|
||||
<div key={payee.id} className="py-3">
|
||||
<p className="text-sm font-semibold text-foreground">{payee.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{payee.category || "Uncategorized"}{payee.accountNumberLast4 ? ` · ${payee.accountNumberLast4}` : ""}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">New Bill</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelCls}>Payee</label>
|
||||
<select value={billForm.payeeId} onChange={(event) => setBillForm((prev) => ({ ...prev, payeeId: event.target.value }))} className={inputCls}>
|
||||
<option value="">No payee</option>
|
||||
{payees.map((payee) => <option key={payee.id} value={payee.id}>{payee.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Bill name</label>
|
||||
<input value={billForm.name} onChange={(event) => setBillForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Amount</label>
|
||||
<input type="number" step="0.01" value={billForm.amount} onChange={(event) => setBillForm((prev) => ({ ...prev, amount: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Due date</label>
|
||||
<input type="date" value={billForm.dueDate} onChange={(event) => setBillForm((prev) => ({ ...prev, dueDate: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Recurrence</label>
|
||||
<select value={billForm.recurrence} onChange={(event) => setBillForm((prev) => ({ ...prev, recurrence: event.target.value }))} className={inputCls}>
|
||||
<option value="none">None</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Reminder days</label>
|
||||
<input type="number" min={0} max={30} value={billForm.reminderDays} onChange={(event) => setBillForm((prev) => ({ ...prev, reminderDays: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={billForm.autopay} onChange={(event) => setBillForm((prev) => ({ ...prev, autopay: event.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||
Autopay enabled externally
|
||||
</label>
|
||||
<button onClick={createBill} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Bill</button>
|
||||
</div>
|
||||
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Bill Schedule</p>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
|
||||
<option value="all">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{filteredBills.length === 0 && <p className="py-8 text-sm text-muted-foreground">No bills for this view.</p>}
|
||||
{filteredBills.map((bill) => (
|
||||
<article key={bill.id} className="py-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-bold text-foreground">{bill.name}</p>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{bill.computedStatus}</span>
|
||||
{bill.autopay && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">autopay</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{money(bill.amount, bill.currency)} due {new Date(bill.dueDate).toLocaleDateString()} · {bill.recurrence}
|
||||
</p>
|
||||
{bill.payee && <p className="mt-1 text-xs text-muted-foreground">Payee: {bill.payee.name}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bill.status !== "paid" && (
|
||||
<button onClick={() => markPaid(bill)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Mark Paid</button>
|
||||
)}
|
||||
{bill.status !== "skipped" && bill.status !== "paid" && (
|
||||
<button onClick={() => updateStatus(bill, "skipped")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Skip</button>
|
||||
)}
|
||||
{bill.status !== "cancelled" && bill.status !== "paid" && (
|
||||
<button onClick={() => updateStatus(bill, "cancelled")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Cancel</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
241
app/credit-score/page.tsx
Normal file
241
app/credit-score/page.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type CreditScoreEntry = {
|
||||
id: string;
|
||||
score: number;
|
||||
bureau: string;
|
||||
source: string;
|
||||
model: string;
|
||||
scoreDate: string;
|
||||
factors?: Record<string, unknown>;
|
||||
change?: number | null;
|
||||
};
|
||||
|
||||
type CreditScoreSummary = {
|
||||
latest: CreditScoreEntry | null;
|
||||
previous: CreditScoreEntry | null;
|
||||
change: number | null;
|
||||
averageScore: number | null;
|
||||
entryCount: number;
|
||||
latestByBureau: CreditScoreEntry[];
|
||||
trend: CreditScoreEntry[];
|
||||
};
|
||||
|
||||
const bureaus = [
|
||||
{ value: "experian", label: "Experian" },
|
||||
{ value: "equifax", label: "Equifax" },
|
||||
{ value: "transunion", label: "TransUnion" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function CreditScorePage() {
|
||||
const [summary, setSummary] = useState<CreditScoreSummary | null>(null);
|
||||
const [entries, setEntries] = useState<CreditScoreEntry[]>([]);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [status, setStatus] = useState("");
|
||||
const [form, setForm] = useState({
|
||||
score: "",
|
||||
bureau: "experian",
|
||||
source: "manual",
|
||||
model: "fico_8",
|
||||
scoreDate: today,
|
||||
positiveFactors: "",
|
||||
negativeFactors: "",
|
||||
});
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (filter === "all") return entries;
|
||||
return entries.filter((entry) => entry.bureau === filter);
|
||||
}, [entries, filter]);
|
||||
|
||||
const scoreBand = (score?: number | null) => {
|
||||
if (!score) return "No score";
|
||||
if (score >= 800) return "Exceptional";
|
||||
if (score >= 740) return "Very good";
|
||||
if (score >= 670) return "Good";
|
||||
if (score >= 580) return "Fair";
|
||||
return "Needs work";
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const query = filter === "all" ? "" : `?bureau=${filter}`;
|
||||
const [summaryRes, entriesRes] = await Promise.all([
|
||||
apiFetch<CreditScoreSummary>("/api/credit-score/summary"),
|
||||
apiFetch<CreditScoreEntry[]>(`/api/credit-score/entries${query}`),
|
||||
]);
|
||||
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
|
||||
if (!entriesRes.error) setEntries(entriesRes.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => setStatus("Unable to load credit score history."));
|
||||
}, [filter]);
|
||||
|
||||
const addEntry = async () => {
|
||||
const score = Number(form.score);
|
||||
if (!score || score < 300 || score > 850) {
|
||||
setStatus("Score must be between 300 and 850.");
|
||||
return;
|
||||
}
|
||||
const factors = {
|
||||
positive: form.positiveFactors.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
negative: form.negativeFactors.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
};
|
||||
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
score,
|
||||
bureau: form.bureau,
|
||||
source: form.source,
|
||||
model: form.model,
|
||||
scoreDate: form.scoreDate,
|
||||
factors,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to add score entry.");
|
||||
return;
|
||||
}
|
||||
const change = res.data?.change;
|
||||
setStatus(change === null || change === undefined ? "Credit score entry added." : `Credit score entry added. Change: ${change > 0 ? "+" : ""}${change}.`);
|
||||
setForm((prev) => ({ ...prev, score: "", positiveFactors: "", negativeFactors: "" }));
|
||||
await load();
|
||||
};
|
||||
|
||||
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="Credit Score" subtitle="Track bureau scores, history, factors, and score movement alerts.">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Latest score</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.latest?.score ?? "--"}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{scoreBand(summary?.latest?.score)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Last change</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">
|
||||
{summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Compared with previous same-bureau entry</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Average score</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.averageScore ?? "--"}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Across saved entries</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Entries</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.entryCount ?? 0}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Manual or imported history</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Add Score Entry</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelCls}>Score</label>
|
||||
<input type="number" min={300} max={850} value={form.score} onChange={(event) => setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Bureau</label>
|
||||
<select value={form.bureau} onChange={(event) => setForm((prev) => ({ ...prev, bureau: event.target.value }))} className={inputCls}>
|
||||
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Model</label>
|
||||
<select value={form.model} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} className={inputCls}>
|
||||
<option value="fico_8">FICO 8</option>
|
||||
<option value="fico_9">FICO 9</option>
|
||||
<option value="vantage_score_3">VantageScore 3</option>
|
||||
<option value="vantage_score_4">VantageScore 4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Score date</label>
|
||||
<input type="date" value={form.scoreDate} onChange={(event) => setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Positive factors</label>
|
||||
<input value={form.positiveFactors} onChange={(event) => setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Negative factors</label>
|
||||
<input value={form.negativeFactors} onChange={(event) => setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={addEntry} className="mt-4 rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
|
||||
Add Score
|
||||
</button>
|
||||
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Bureau Snapshot</p>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
|
||||
<option value="all">All bureaus</option>
|
||||
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{(summary?.latestByBureau ?? []).map((entry) => (
|
||||
<div key={entry.bureau} className="rounded-lg border border-border bg-background/50 p-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">{entry.bureau}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{entry.score}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
{(summary?.latestByBureau?.length ?? 0) === 0 && <p className="text-sm text-muted-foreground">No bureau scores yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Score History</p>
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{filteredEntries.length === 0 && <p className="py-8 text-sm text-muted-foreground">No score entries for this view.</p>}
|
||||
{filteredEntries.map((entry) => {
|
||||
const factors = entry.factors ?? {};
|
||||
const positive = Array.isArray(factors.positive) ? factors.positive : [];
|
||||
const negative = Array.isArray(factors.negative) ? factors.negative : [];
|
||||
return (
|
||||
<article key={entry.id} className="py-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-lg font-bold text-foreground">{entry.score}</p>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.bureau}</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.model}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}</p>
|
||||
{(positive.length > 0 || negative.length > 0) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""}
|
||||
{positive.length > 0 && negative.length > 0 ? " · " : ""}
|
||||
{negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{scoreBand(entry.score)}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
244
app/notifications/page.tsx
Normal file
244
app/notifications/page.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type NotificationPreference = {
|
||||
emailEnabled: boolean;
|
||||
pushEnabled: boolean;
|
||||
minSeverity: "info" | "warning" | "critical";
|
||||
};
|
||||
|
||||
type LedgerNotification = {
|
||||
id: string;
|
||||
type: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
title: string;
|
||||
body: string;
|
||||
channels: string[];
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type VapidStatus = {
|
||||
enabled: boolean;
|
||||
publicKey: string | null;
|
||||
};
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<LedgerNotification[]>([]);
|
||||
const [preferences, setPreferences] = useState<NotificationPreference>({
|
||||
emailEnabled: true,
|
||||
pushEnabled: false,
|
||||
minSeverity: "info",
|
||||
});
|
||||
const [vapid, setVapid] = useState<VapidStatus>({ enabled: false, publicKey: null });
|
||||
const [status, setStatus] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const unreadCount = useMemo(() => notifications.filter((item) => !item.readAt).length, [notifications]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [listRes, prefRes, vapidRes] = await Promise.all([
|
||||
apiFetch<LedgerNotification[]>("/api/notifications"),
|
||||
apiFetch<NotificationPreference>("/api/notifications/preferences"),
|
||||
apiFetch<VapidStatus>("/api/notifications/vapid-public-key"),
|
||||
]);
|
||||
if (!listRes.error) setNotifications(listRes.data ?? []);
|
||||
if (!prefRes.error && prefRes.data) setPreferences(prefRes.data);
|
||||
if (!vapidRes.error && vapidRes.data) setVapid(vapidRes.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const updatePreferences = async (patch: Partial<NotificationPreference>) => {
|
||||
const next = { ...preferences, ...patch };
|
||||
setPreferences(next);
|
||||
const res = await apiFetch<NotificationPreference>("/api/notifications/preferences", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Could not update notification preferences.");
|
||||
return;
|
||||
}
|
||||
if (res.data) setPreferences(res.data);
|
||||
setStatus("Notification preferences updated.");
|
||||
};
|
||||
|
||||
const enablePush = async () => {
|
||||
setStatus("");
|
||||
if (!vapid.enabled || !vapid.publicKey) {
|
||||
setStatus("Push notifications need VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY on the backend.");
|
||||
return;
|
||||
}
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
setStatus("This browser does not support web push notifications.");
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
setStatus("Browser notification permission was not granted.");
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
const subscription = existing ?? await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
|
||||
});
|
||||
|
||||
const res = await apiFetch("/api/notifications/push-subscriptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Could not save push subscription.");
|
||||
return;
|
||||
}
|
||||
setPreferences((prev) => ({ ...prev, pushEnabled: true }));
|
||||
setStatus("Push notifications enabled for this browser.");
|
||||
};
|
||||
|
||||
const sendTest = async () => {
|
||||
setStatus("Sending test notification...");
|
||||
const res = await apiFetch("/api/notifications/test", { method: "POST" });
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Test notification failed.");
|
||||
return;
|
||||
}
|
||||
setStatus("Test notification sent.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
const res = await apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" });
|
||||
if (!res.error) {
|
||||
setNotifications((items) => items.map((item) => item.id === id ? { ...item, readAt: new Date().toISOString() } : item));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
const res = await apiFetch("/api/notifications/read-all", { method: "POST" });
|
||||
if (!res.error) {
|
||||
const now = new Date().toISOString();
|
||||
setNotifications((items) => items.map((item) => ({ ...item, readAt: item.readAt ?? now })));
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<AppShell title="Notifications" subtitle="Manage in-app, SMTP email, and browser push alerts.">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">SMTP Email</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Send important LedgerOne alerts through the configured SMTP transport.</p>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.emailEnabled}
|
||||
onChange={(event) => updatePreferences({ emailEnabled: event.target.checked })}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
Email alerts enabled
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Browser Push</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{vapid.enabled ? "Register this browser for web push alerts." : "Backend VAPID keys are not configured."}
|
||||
</p>
|
||||
<button
|
||||
onClick={enablePush}
|
||||
disabled={!vapid.enabled}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{preferences.pushEnabled ? "Refresh Push Registration" : "Enable Push"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Delivery Threshold</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Email and push delivery only run at or above this severity.</p>
|
||||
<select
|
||||
value={preferences.minSeverity}
|
||||
onChange={(event) => updatePreferences({ minSeverity: event.target.value as NotificationPreference["minSeverity"] })}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="info">Info and above</option>
|
||||
<option value="warning">Warning and above</option>
|
||||
<option value="critical">Critical only</option>
|
||||
</select>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Notification Center</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{unreadCount} unread alert{unreadCount === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={sendTest} className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90">
|
||||
Send Test
|
||||
</button>
|
||||
<button onClick={markAllRead} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary/40">
|
||||
Mark All Read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
|
||||
|
||||
<div className="mt-6 divide-y divide-border">
|
||||
{loading && <p className="py-8 text-sm text-muted-foreground">Loading notifications...</p>}
|
||||
{!loading && notifications.length === 0 && <p className="py-8 text-sm text-muted-foreground">No notifications yet.</p>}
|
||||
{!loading && notifications.map((item) => (
|
||||
<article key={item.id} className="py-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!item.readAt && <span className="h-2 w-2 rounded-full bg-primary" />}
|
||||
<p className="text-sm font-bold text-foreground">{item.title}</p>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{item.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.body}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{new Date(item.createdAt).toLocaleString()} · {item.channels.join(", ") || "in_app"}
|
||||
</p>
|
||||
</div>
|
||||
{!item.readAt && (
|
||||
<button onClick={() => markRead(item.id)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-secondary/40">
|
||||
Mark Read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type TransactionRow = {
|
||||
id: string;
|
||||
viewRef: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
amount: string;
|
||||
@ -23,11 +23,10 @@ type TransactionRow = {
|
||||
status?: string;
|
||||
hidden?: boolean;
|
||||
date: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type Account = {
|
||||
id: string;
|
||||
viewRef: string;
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string | null;
|
||||
@ -69,7 +68,7 @@ type CsvPreview = {
|
||||
fileName: string;
|
||||
headerSignature: string;
|
||||
headers: string[];
|
||||
sampleRows: Record<string, string>[];
|
||||
rowCount: number;
|
||||
mapping: Partial<CsvMapping>;
|
||||
remembered: boolean;
|
||||
};
|
||||
@ -114,7 +113,7 @@ export default function TransactionsPage() {
|
||||
});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [manualForm, setManualForm] = useState({
|
||||
accountId: "",
|
||||
accountRef: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: "",
|
||||
amount: "",
|
||||
@ -125,7 +124,7 @@ export default function TransactionsPage() {
|
||||
splitMinePercent: "50",
|
||||
splitYoursPercent: "50",
|
||||
});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingRef, setEditingRef] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
category: "",
|
||||
note: "",
|
||||
@ -369,7 +368,7 @@ export default function TransactionsPage() {
|
||||
const res = await apiFetch<unknown>("/api/transactions/manual", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
accountId: manualForm.accountId || undefined,
|
||||
accountId: manualForm.accountRef || undefined,
|
||||
date: manualForm.date,
|
||||
description: manualForm.description,
|
||||
amount,
|
||||
@ -389,7 +388,7 @@ export default function TransactionsPage() {
|
||||
};
|
||||
|
||||
const startEdit = (row: TransactionRow) => {
|
||||
setEditingId(row.id);
|
||||
setEditingRef(row.viewRef);
|
||||
setEditForm({
|
||||
category: row.category ?? "",
|
||||
note: row.note ?? "",
|
||||
@ -402,9 +401,9 @@ export default function TransactionsPage() {
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId) return;
|
||||
if (!editingRef) return;
|
||||
setStatus("Saving edits...");
|
||||
const res = await apiFetch<unknown>(`/api/transactions/${editingId}/derived`, {
|
||||
const res = await apiFetch<unknown>(`/api/transactions/${editingRef}/derived`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
userCategory: editForm.category || undefined,
|
||||
@ -415,7 +414,7 @@ export default function TransactionsPage() {
|
||||
}),
|
||||
});
|
||||
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
|
||||
setEditingId(null);
|
||||
setEditingRef(null);
|
||||
setStatus("Transaction updated.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
@ -545,25 +544,27 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Preview: {csvPreview.fileName}</p>
|
||||
<p className="text-sm font-semibold text-foreground">Columns: {csvPreview.fileName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-secondary/30 text-muted-foreground">
|
||||
<tr>
|
||||
{csvPreview.headers.map((header) => (
|
||||
<th key={header} className="whitespace-nowrap px-3 py-2 font-semibold">{header}</th>
|
||||
))}
|
||||
<th className="whitespace-nowrap px-3 py-2 font-semibold">Column</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 font-semibold">Mapped as</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.sampleRows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-border text-foreground">
|
||||
{csvPreview.headers.map((header) => (
|
||||
<td key={header} className="max-w-48 truncate px-3 py-2">{row[header] ?? ""}</td>
|
||||
))}
|
||||
{csvPreview.headers.map((header) => {
|
||||
const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0];
|
||||
return (
|
||||
<tr key={header} className="border-t border-border text-foreground">
|
||||
<td className="max-w-48 truncate px-3 py-2">{header}</td>
|
||||
<td className="px-3 py-2 capitalize text-muted-foreground">{mapped ?? "Not mapped"}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -599,10 +600,10 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Account</label>
|
||||
<select value={manualForm.accountId} onChange={(e) => setManualForm((p) => ({ ...p, accountId: e.target.value }))} className={inputCls}>
|
||||
<select value={manualForm.accountRef} onChange={(e) => setManualForm((p) => ({ ...p, accountRef: e.target.value }))} className={inputCls}>
|
||||
<option value="">— No account —</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
|
||||
<option key={a.viewRef} value={a.viewRef}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@ -794,8 +795,8 @@ export default function TransactionsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) =>
|
||||
editingId === row.id ? (
|
||||
<tr key={row.id} className="border-b border-border bg-secondary/10">
|
||||
editingRef === row.viewRef ? (
|
||||
<tr key={row.viewRef} className="border-b border-border bg-secondary/10">
|
||||
<td className="px-4 py-3" colSpan={4}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
@ -863,12 +864,12 @@ export default function TransactionsPage() {
|
||||
<td className="px-4 py-3" colSpan={3}>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
|
||||
<button onClick={() => setEditingId(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
|
||||
<button onClick={() => setEditingRef(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={row.id} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
|
||||
<tr key={row.viewRef} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
|
||||
<td className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</td>
|
||||
|
||||
@ -10,8 +10,11 @@ const navItems = [
|
||||
{ href: "/app", label: "Dashboard" },
|
||||
{ href: "/app/connect", label: "Accounts" },
|
||||
{ href: "/transactions", label: "Transactions" },
|
||||
{ href: "/bills", label: "Bills" },
|
||||
{ href: "/credit-score", label: "Credit Score" },
|
||||
{ href: "/rules", label: "Rules" },
|
||||
{ href: "/exports", label: "Exports" },
|
||||
{ href: "/notifications", label: "Notifications" },
|
||||
{ href: "/tax", label: "Tax" },
|
||||
{ href: "/settings/households", label: "Households" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
|
||||
36
public/sw.js
Normal file
36
public/sw.js
Normal file
@ -0,0 +1,36 @@
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
const title = data.title || "LedgerOne";
|
||||
const options = {
|
||||
body: data.body || "You have a new LedgerOne notification.",
|
||||
icon: "/favicon.ico",
|
||||
badge: "/favicon.ico",
|
||||
data: {
|
||||
url: data.url || "/notifications",
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/notifications";
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ("focus" in client && client.url.includes(url)) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user