diff --git a/app/budgets/page.tsx b/app/budgets/page.tsx index 220513c..45fd47b 100644 --- a/app/budgets/page.tsx +++ b/app/budgets/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { useDemo } from "@/src/components/demo-context"; -import { money } from "@/src/lib/demo-data"; import { currencySymbol, monthLabel } from "@/src/format"; +import { money } from "@/src/lib/demo-data"; export default function BudgetsPage() { const { activeSpace, categories, currency, source, month, saveBudgets } = useDemo(); @@ -12,25 +12,36 @@ export default function BudgetsPage() { const [error, setError] = useState(""); useEffect(() => { - setLimits(Object.fromEntries(categories.map((category) => [category.key, category.budget ? String(category.budget) : ""]))); + setLimits( + Object.fromEntries( + categories.map((category) => [category.key, category.budget ? String(category.budget) : ""]), + ), + ); }, [categories]); return (
-

{activeSpace} · {monthLabel(month)}

+

+ {activeSpace} · {monthLabel(month)} +

Monthly budgets

Set a spending limit for each category in {activeSpace}.

-
{ - event.preventDefault(); - setError(""); - const budgets = Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, Number(value) || 0])); - void saveBudgets(budgets) - .then(() => setSaved(true)) - .catch((cause: Error) => setError(cause.message)); - }} className="rounded-2xl border border-line bg-card p-5 sm:p-7"> + { + event.preventDefault(); + setError(""); + const budgets = Object.fromEntries( + Object.entries(limits).map(([key, value]) => [key, Number(value) || 0]), + ); + void saveBudgets(budgets) + .then(() => setSaved(true)) + .catch((cause: Error) => setError(cause.message)); + }} + className="rounded-2xl border border-line bg-card p-5 sm:p-7" + >

Category limits

@@ -43,13 +54,18 @@ export default function BudgetsPage() { const limit = Number(limits[category.key] || 0); const spentPercent = limit ? Math.round((category.amount / limit) * 100) : 0; return ( -
+
- {currencySymbol(currency)} + + {currencySymbol(currency)} + { setSaved(false); setLimits({ ...limits, [category.key]: event.target.value }); }} + onChange={(event) => { + setSaved(false); + setLimits({ ...limits, [category.key]: event.target.value }); + }} className="w-full rounded-lg border border-line bg-paper py-2 pl-8 pr-3 text-right tabular-nums outline-none focus:border-ledger" />
Spent {money(category.amount, currency)} - = 80 ? "font-semibold text-amber" : ""}>{limit ? `${spentPercent}% used` : "No limit"} + = 80 ? "font-semibold text-amber" : ""}> + {limit ? `${spentPercent}% used` : "No limit"} +
- {limit ?
-
= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`} style={{ width: `${Math.min(spentPercent, 100)}%` }} /> -
: null} + {limit ? ( +
+
= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`} + style={{ width: `${Math.min(spentPercent, 100)}%` }} + /> +
+ ) : null}
); })}
-

{error || (saved ? (source === "connected" ? "Budgets saved." : "Budgets saved in this preview.") : source === "connected" ? "Changes will update this space." : "Changes are local to this preview.")}

- +

+ {error || + (saved + ? source === "connected" + ? "Budgets saved." + : "Budgets saved in this preview." + : source === "connected" + ? "Changes will update this space." + : "Changes are local to this preview.")} +

+
diff --git a/app/connect/page.tsx b/app/connect/page.tsx index 13aeaa9..c45d70e 100644 --- a/app/connect/page.tsx +++ b/app/connect/page.tsx @@ -1,9 +1,17 @@ "use client"; -import { useEffect, useState, type FormEvent } from "react"; -import { api, DEMO_ACCOUNT_EMAIL, isDemoSession, oidcConfig, saveTokens, session, startDemoSession } from "@/src/api/client"; -import type { ClientConfig } from "@/src/api/types"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { + api, + DEMO_ACCOUNT_EMAIL, + isDemoSession, + oidcConfig, + saveTokens, + session, + startDemoSession, +} from "@/src/api/client"; import type { DevToken } from "@/src/api/generated"; +import type { ClientConfig } from "@/src/api/types"; import * as oidc from "@/src/oidc"; export default function ConnectPage() { @@ -12,27 +20,29 @@ export default function ConnectPage() { const [error, setError] = useState(""); const [ready, setReady] = useState(false); - const destination = () => { + const destination = useCallback(() => { const requested = new URLSearchParams(window.location.search).get("returnTo"); return requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/"; - }; + }, []); useEffect(() => { - void api("/v1/config").then(async (clientConfig) => { - session.config = clientConfig; - setConfig(clientConfig); - const oidcSettings = oidcConfig(); - if (oidcSettings) { - const tokens = await oidc.finish(oidcSettings); - if (tokens) saveTokens(tokens); - } - if (session.token || isDemoSession()) window.location.assign(destination()); - setReady(true); - }).catch(() => { - setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`); - setReady(true); - }); - }, []); + void api("/v1/config") + .then(async (clientConfig) => { + session.config = clientConfig; + setConfig(clientConfig); + const oidcSettings = oidcConfig(); + if (oidcSettings) { + const tokens = await oidc.finish(oidcSettings); + if (tokens) saveTokens(tokens); + } + if (session.token || isDemoSession()) window.location.assign(destination()); + setReady(true); + }) + .catch(() => { + setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`); + setReady(true); + }); + }, [destination]); const signInDev = async (event: FormEvent) => { event.preventDefault(); @@ -52,7 +62,7 @@ export default function ConnectPage() { body: JSON.stringify({ email: email.trim() }), }); if (!response.ok) throw new Error("Couldn't sign in. Check the email address."); - const token = await response.json() as DevToken; + const token = (await response.json()) as DevToken; saveTokens({ access_token: token.access_token, expires_in: 12 * 3600 }); window.location.assign(destination()); } catch (cause) { @@ -62,15 +72,23 @@ export default function ConnectPage() { const login = (prompt?: string) => { const settings = oidcConfig(); - if (settings) void oidc.login(settings, { prompt }).catch(() => setError("Couldn't reach the sign-in page. Try again.")); + if (settings) + void oidc + .login(settings, { prompt }) + .catch(() => setError("Couldn't reach the sign-in page. Try again.")); }; return (
-
); diff --git a/app/globals.css b/app/globals.css index e2c8264..e3b0bac 100644 --- a/app/globals.css +++ b/app/globals.css @@ -79,9 +79,7 @@ body { } body { - background: - radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%), - var(--paper); + background: radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%), var(--paper); color: var(--ink); font-family: var(--font-hind); -webkit-font-smoothing: antialiased; diff --git a/app/receipts/[id]/page.tsx b/app/receipts/[id]/page.tsx index 3c2647f..682a3eb 100644 --- a/app/receipts/[id]/page.tsx +++ b/app/receipts/[id]/page.tsx @@ -6,8 +6,8 @@ import { useEffect, useMemo, useState } from "react"; import { api, request } from "@/src/api/client"; import type { DocSchema, DocumentOut, FieldSpec, Issue } from "@/src/api/types"; import { useDemo } from "@/src/components/demo-context"; -import { dateLabel } from "@/src/lib/demo-data"; import { nice, parseAmount, title, toDMY, toISO } from "@/src/format"; +import { dateLabel } from "@/src/lib/demo-data"; type FieldKind = NonNullable; type FormData = Record; @@ -35,14 +35,22 @@ function hasContent(value: unknown): boolean { function issueText(issue: Issue) { switch (issue.rule) { - case "empty": return "Nothing was read automatically. Type the main details."; - case "missing": return `The ${nice(issue.detail).replace(/\[\d+\]/, "")} is missing.`; - case "date_parse": return "The date isn't readable. Use DD/MM/YYYY."; - case "total_positive": return "The total should be more than zero."; - case "items_sum": return `The items don't add up: ${issue.detail.replace("!=", "but")}.`; - case "total_math": return `Subtotal plus VAT doesn't match the total (${issue.detail.replace("!=", "≠")}).`; - case "line_math": return `A line's quantity × price doesn't match its amount (${issue.detail}).`; - default: return issue.detail; + case "empty": + return "Nothing was read automatically. Type the main details."; + case "missing": + return `The ${nice(issue.detail).replace(/\[\d+\]/, "")} is missing.`; + case "date_parse": + return "The date isn't readable. Use DD/MM/YYYY."; + case "total_positive": + return "The total should be more than zero."; + case "items_sum": + return `The items don't add up: ${issue.detail.replace("!=", "but")}.`; + case "total_math": + return `Subtotal plus VAT doesn't match the total (${issue.detail.replace("!=", "≠")}).`; + case "line_math": + return `A line's quantity × price doesn't match its amount (${issue.detail}).`; + default: + return issue.detail; } } @@ -70,21 +78,22 @@ export default function ReviewReceiptPage() { useEffect(() => { if (source !== "connected") return; let cancelled = false; - void Promise.all([ - api(`/v1/documents/${params.id}`), - api("/v1/categories"), - ]).then(async ([loaded, availableCategories]) => { - const loadedSchema = await api(`/v1/schemas/${loaded.doc_type}`); - if (cancelled) return; - setDocument(loaded); - setSchema(loadedSchema); - setFormData(loaded.data ?? {}); - setCategory(loaded.category ?? availableCategories[0] ?? ""); - setAllCategories(availableCategories); - }).catch((cause: Error) => { - if (!cancelled) setError(cause.message); - }); - return () => { cancelled = true; }; + void Promise.all([api(`/v1/documents/${params.id}`), api("/v1/categories")]) + .then(async ([loaded, availableCategories]) => { + const loadedSchema = await api(`/v1/schemas/${loaded.doc_type}`); + if (cancelled) return; + setDocument(loaded); + setSchema(loadedSchema); + setFormData(loaded.data ?? {}); + setCategory(loaded.category ?? availableCategories[0] ?? ""); + setAllCategories(availableCategories); + }) + .catch((cause: Error) => { + if (!cancelled) setError(cause.message); + }); + return () => { + cancelled = true; + }; }, [source, params.id]); useEffect(() => { @@ -117,13 +126,17 @@ export default function ReviewReceiptPage() { useEffect(() => { if (source !== "connected") return; let objectUrl: string | null = null; - void request(`/v1/documents/${params.id}/file`).then(async (response) => { - if (!response.ok) return; - const blob = await response.blob(); - objectUrl = URL.createObjectURL(blob); - setPhoto({ url: objectUrl, pdf: blob.type === "application/pdf" }); - }).catch(() => {}); - return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }; + void request(`/v1/documents/${params.id}/file`) + .then(async (response) => { + if (!response.ok) return; + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + setPhoto({ url: objectUrl, pdf: blob.type === "application/pdf" }); + }) + .catch(() => {}); + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; }, [source, params.id]); const fields = useMemo(() => Object.entries(schema?.properties ?? {}), [schema]); @@ -132,19 +145,35 @@ export default function ReviewReceiptPage() { if (!receipt) return; setSampleMerchant(receipt.merchant); setSampleAmount(String(receipt.amount)); - setCategory(categories.find((item) => item.name === receipt.category)?.key ?? receipt.category.toLowerCase().replaceAll(" ", "_")); + setCategory( + categories.find((item) => item.name === receipt.category)?.key ?? + receipt.category.toLowerCase().replaceAll(" ", "_"), + ); }, [receipt, categories]); - const collectData = () => Object.fromEntries(fields.map(([name, spec]) => { - const kind = spec["x-kind"] ?? "text"; - if (kind !== "rows") return [name, readField(kind, formData[name] == null ? "" : String(formData[name]))]; - const columns = Object.entries(spec.items?.properties ?? {}); - const rows = Array.isArray(formData[name]) ? formData[name] as FormData[] : []; - return [name, rows.map((row) => Object.fromEntries(columns.map(([column, columnSpec]) => { - const columnKind = columnSpec["x-kind"] ?? "text"; - return [column, readField(columnKind, row[column] == null ? "" : String(row[column]))]; - }))).filter((row) => Object.values(row).some((value) => value != null))]; - })); + const collectData = () => + Object.fromEntries( + fields.map(([name, spec]) => { + const kind = spec["x-kind"] ?? "text"; + if (kind !== "rows") + return [name, readField(kind, formData[name] == null ? "" : String(formData[name]))]; + const columns = Object.entries(spec.items?.properties ?? {}); + const rows = Array.isArray(formData[name]) ? (formData[name] as FormData[]) : []; + return [ + name, + rows + .map((row) => + Object.fromEntries( + columns.map(([column, columnSpec]) => { + const columnKind = columnSpec["x-kind"] ?? "text"; + return [column, readField(columnKind, row[column] == null ? "" : String(row[column]))]; + }), + ), + ) + .filter((row) => Object.values(row).some((value) => value != null)), + ]; + }), + ); const canConfirm = hasContent(collectData()); const save = async (confirmed = false) => { @@ -185,26 +214,55 @@ export default function ReviewReceiptPage() { } }; - if (!receipt && source === "sample" && loading) return

Loading receipt…

; + if (!receipt && source === "sample" && loading) + return ( +

+ Loading receipt… +

+ ); if (!receipt && source === "sample") { return (

Receipt not found

This receipt is not part of the sample set.

- Back to receipts + + Back to receipts +
); } - if (source === "connected" && (!document || !schema)) return

{error || "Loading receipt details…"}

; + if (source === "connected" && (!document || !schema)) + return ( +

+ {error || "Loading receipt details…"} +

+ ); if (source === "connected" && document?.status === "processing") { return (
-
+
-

Reading receipt

@@ -212,10 +270,24 @@ export default function ReviewReceiptPage() {
-
    +
    1. 01 - + Receipt uploaded
    2. @@ -230,115 +302,364 @@ export default function ReviewReceiptPage() {
    -

    This page will show the extracted fields when they are ready to review.

    - Back to receipts +

    + This page will show the extracted fields when they are ready to review. +

    + + Back to receipts +
); } - const status = document?.status === "ready" ? "Ready" : document?.status === "needs_review" ? "Needs review" : document?.status === "processing" ? "Processing" : document?.status === "failed" ? "Unreadable" : receipt?.status ?? "Processing"; - const statusNote = document?.status === "ready" ? "Checked and counted in your spending." : document?.status === "needs_review" ? document.error ?? "Please check these details before confirming." : document?.status === "processing" ? "Still reading this receipt…" : document?.status === "failed" ? document.error ?? "This file couldn't be read." : "Review and edit the receipt fields."; - const merchantLabel = document?.doc_type === "invoice" ? "Supplier" : document?.doc_type === "bank_statement" ? "Bank" : "Merchant"; + const status = + document?.status === "ready" + ? "Ready" + : document?.status === "needs_review" + ? "Needs review" + : document?.status === "processing" + ? "Processing" + : document?.status === "failed" + ? "Unreadable" + : (receipt?.status ?? "Processing"); + const statusNote = + document?.status === "ready" + ? "Checked and counted in your spending." + : document?.status === "needs_review" + ? (document.error ?? "Please check these details before confirming.") + : document?.status === "processing" + ? "Still reading this receipt…" + : document?.status === "failed" + ? (document.error ?? "This file couldn't be read.") + : "Review and edit the receipt fields."; + const merchantLabel = + document?.doc_type === "invoice" + ? "Supplier" + : document?.doc_type === "bank_statement" + ? "Bank" + : "Merchant"; const amountLabel = document?.doc_type === "bank_statement" ? "Closing balance" : "Total amount"; return (
- Back to receipts + + Back to receipts +
-

{dateLabel(document?.date ?? receipt?.date ?? document?.created_at.slice(0, 10) ?? "")} · {params.id}

-

{document?.merchant || receipt?.merchant || document?.filename || "Receipt"}

+

+ {dateLabel(document?.date ?? receipt?.date ?? document?.created_at.slice(0, 10) ?? "")} ·{" "} + {params.id} +

+

+ {document?.merchant || receipt?.merchant || document?.filename || "Receipt"} +

{statusNote}

- {status} + + {status} +
- {document?.possible_duplicate_of ?
-

This looks like a receipt you already added.

- View the other one -
: null} + {document?.possible_duplicate_of ? ( +
+

This looks like a receipt you already added.

+ + View the other one + +
+ ) : null}
- {photo?.pdf ? Open receipt PDF : photo ? ( + {photo?.pdf ? ( + + Open receipt PDF + + ) : photo ? ( // eslint-disable-next-line @next/next/no-img-element - Uploaded receipt - ) :
-
- + Uploaded receipt + ) : ( +
+
+ +
+

Receipt image preview

+

+ {source === "connected" + ? "The image is not available." + : "A real receipt image appears here when an account is connected."} +

-

Receipt image preview

-

{source === "connected" ? "The image is not available." : "A real receipt image appears here when an account is connected."}

-
} + )}
-
{ event.preventDefault(); void save(); }} className="rounded-2xl border border-line bg-card p-5 sm:p-6"> + { + event.preventDefault(); + void save(); + }} + className="rounded-2xl border border-line bg-card p-5 sm:p-6" + >

Purchase details

Review and edit the details read from your receipt.

- {document?.issues.length && document.status !== "ready" ?
-

Doesn’t add up yet

-
    {document.issues.map((issue, index) =>
  • {issueText(issue)}
  • )}
-
: null} + {document?.issues.length && document.status !== "ready" ? ( +
+

Doesn’t add up yet

+
    + {document.issues.map((issue, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server's list, shown as is and never reordered +
  • {issueText(issue)}
  • + ))} +
+
+ ) : null}
- - { + setCategory(event.target.value); + setSaved(false); + }} + className={inputClass()} + > + {allCategories.map((item) => ( + + ))}
{fields.map(([name, spec]) => { const kind = spec["x-kind"] ?? "text"; if (kind === "rows") { const columns = Object.entries(spec.items?.properties ?? {}); - const rows = Array.isArray(formData[name]) ? formData[name] as FormData[] : []; + const rows = Array.isArray(formData[name]) ? (formData[name] as FormData[]) : []; const updateRow = (rowIndex: number, column: string, value: string) => { - const nextRows = rows.map((row, index) => index === rowIndex ? { ...row, [column]: value } : row); + const nextRows = rows.map((row, index) => + index === rowIndex ? { ...row, [column]: value } : row, + ); setFormData((current) => ({ ...current, [name]: nextRows })); setSaved(false); }; - return
-

{title(name)}

- {rows.length ?
- - {columns.map(([column]) => )} - {rows.map((row, rowIndex) => - {columns.map(([column, columnSpec]) => { - const columnKind = columnSpec["x-kind"] ?? "text"; - return ; - })} - - )} -
{title(column)}Actions
updateRow(rowIndex, column, event.target.value)} className="w-full min-w-24 rounded-md border border-line bg-paper px-2 py-1.5 outline-none focus:border-ledger" />
-
:

No {nice(name)} added.

} - -
; + return ( +
+

{title(name)}

+ {rows.length ? ( +
+ + + + {columns.map(([column]) => ( + + ))} + + + + + {rows.map((row, rowIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows have no ID; their inputs are controlled by the row data, so a removed row's DOM shows the right values + + {columns.map(([column, columnSpec]) => { + const columnKind = columnSpec["x-kind"] ?? "text"; + return ( + + ); + })} + + + ))} + +
+ {title(column)} + + Actions +
+ updateRow(rowIndex, column, event.target.value)} + className="w-full min-w-24 rounded-md border border-line bg-paper px-2 py-1.5 outline-none focus:border-ledger" + /> + + +
+
+ ) : ( +

No {nice(name)} added.

+ )} + +
+ ); } const value = formData[name]; - return
- - { setFormData((current) => ({ ...current, [name]: event.target.value })); setSaved(false); }} className={inputClass()} /> -
; + return ( +
+ + { + setFormData((current) => ({ ...current, [name]: event.target.value })); + setSaved(false); + }} + className={inputClass()} + /> +
+ ); })} - {source === "sample" ? <> -
- - { setSampleMerchant(event.target.value); setSaved(false); }} className={inputClass()} /> -
-
- - { setSampleAmount(event.target.value); setSaved(false); }} className={inputClass()} /> -
- : null} + {source === "sample" ? ( + <> +
+ + { + setSampleMerchant(event.target.value); + setSaved(false); + }} + className={inputClass()} + /> +
+
+ + { + setSampleAmount(event.target.value); + setSaved(false); + }} + className={inputClass()} + /> +
+ + ) : null}
-

{error || (saved ? source === "connected" ? document?.status === "ready" ? "Receipt confirmed and saved." : document?.issues.length ? "Saved. Some numbers still need a check." : "Saved. Confirm the details when they look right." : "Preview saved for this screen." : source === "connected" ? "Check the extracted details, then confirm them." : "Sample mode: values are shown for preview." )}

+

+ {error || + (saved + ? source === "connected" + ? document?.status === "ready" + ? "Receipt confirmed and saved." + : document?.issues.length + ? "Saved. Some numbers still need a check." + : "Saved. Confirm the details when they look right." + : "Preview saved for this screen." + : source === "connected" + ? "Check the extracted details, then confirm them." + : "Sample mode: values are shown for preview.")} +

- {source === "connected" ? : null} - - {document?.status === "needs_review" ? : null} + {source === "connected" ? ( + + ) : null} + + {document?.status === "needs_review" ? ( + + ) : null}
diff --git a/app/receipts/page.tsx b/app/receipts/page.tsx index 517dc04..300dcf7 100644 --- a/app/receipts/page.tsx +++ b/app/receipts/page.tsx @@ -4,21 +4,28 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { request } from "@/src/api/client"; import { useDemo } from "@/src/components/demo-context"; -import { dateLabel, money } from "@/src/lib/demo-data"; -import { monthLabel } from "@/src/format"; import { MonthNav } from "@/src/components/month-nav"; +import { monthLabel } from "@/src/format"; +import { dateLabel, money } from "@/src/lib/demo-data"; export default function ReceiptsPage() { - const { activeSpace, activeSpaceId, receipts, categories, currency, month, source, downloadCsv } = useDemo(); + const { activeSpace, activeSpaceId, receipts, categories, currency, month, source, downloadCsv } = + useDemo(); const [filter, setFilter] = useState("All receipts"); const [query, setQuery] = useState(""); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(""); - const shown = useMemo(() => receipts.filter((receipt) => { - const matchesFilter = filter === "All receipts" || (filter === "Needs review" ? receipt.status === filter : receipt.category === filter); - const searchText = `${receipt.merchant} ${receipt.category}`.toLowerCase(); - return matchesFilter && searchText.includes(query.trim().toLowerCase()); - }), [filter, query]); + const shown = useMemo( + () => + receipts.filter((receipt) => { + const matchesFilter = + filter === "All receipts" || + (filter === "Needs review" ? receipt.status === filter : receipt.category === filter); + const searchText = `${receipt.merchant} ${receipt.category}`.toLowerCase(); + return matchesFilter && searchText.includes(query.trim().toLowerCase()); + }), + [receipts, filter, query], + ); const filters = ["All receipts", "Needs review", ...new Set(categories.map((category) => category.name))]; useEffect(() => { const status = new URLSearchParams(window.location.search).get("status"); @@ -51,50 +58,106 @@ export default function ReceiptsPage() {
-

{activeSpace} · {monthLabel(month)}

+

+ {activeSpace} · {monthLabel(month)} +

Receipts

Browse and review your purchases.

- - + +
-
+
{filters.map((name) => ( - ))} -
+
-

{shown.length} {shown.length === 1 ? "receipt" : "receipts"}

+

+ {shown.length} {shown.length === 1 ? "receipt" : "receipts"} +

Newest first - - {source === "connected" ? : null} + + {source === "connected" ? ( + + ) : null}
- {exportError ?

{exportError}

: null} + {exportError ? ( +

+ {exportError} +

+ ) : null} {shown.length ? (
{shown.map((receipt) => ( - + {receipt.merchant} - {receipt.category} · {dateLabel(receipt.date)} + + {receipt.category} · {dateLabel(receipt.date)} + {dateLabel(receipt.date)} - {money(receipt.amount, currency)} - {receipt.status} + + {money(receipt.amount, currency)} + + + {receipt.status} + ))} diff --git a/app/scan/page.tsx b/app/scan/page.tsx index 7b493b2..e5c4a67 100644 --- a/app/scan/page.tsx +++ b/app/scan/page.tsx @@ -9,7 +9,13 @@ type UploadStatus = { id: number; name: string; status: string; failed?: boolean function deviceLabel(): string { const ua = navigator.userAgent; const os = /iPhone|iPad/.test(ua) ? "iPhone" : /Android/.test(ua) ? "Android phone" : "Phone"; - const browser = /CriOS|Chrome/.test(ua) ? "Chrome" : /Safari/.test(ua) ? "Safari" : /Firefox/.test(ua) ? "Firefox" : "browser"; + const browser = /CriOS|Chrome/.test(ua) + ? "Chrome" + : /Safari/.test(ua) + ? "Safari" + : /Firefox/.test(ua) + ? "Firefox" + : "browser"; return `${browser} on ${os}`; } @@ -40,10 +46,12 @@ export default function ScanPage() { setMessage("This code is no longer valid. Show a new one and scan again."); return; } - const claim = await response.json() as PairClaim; + const claim = (await response.json()) as PairClaim; if (cancelled) return; setMessage(`Allow this phone on “${claim.shower || "your computer"}”…`); - events = new EventSource(`/v1/pair/offers/${claim.offer_id}/events?key=${encodeURIComponent(claim.claimer_key)}`); + events = new EventSource( + `/v1/pair/offers/${claim.offer_id}/events?key=${encodeURIComponent(claim.claimer_key)}`, + ); events.addEventListener("approved", async () => { events?.close(); const collected = await fetch(`/v1/pair/offers/${claim.offer_id}/collect`, { @@ -54,17 +62,26 @@ export default function ScanPage() { setMessage("Couldn't connect. Scan a new code."); return; } - setGrant(await collected.json() as PairGrant); + setGrant((await collected.json()) as PairGrant); setMessage("Connected. Photos go straight to ScribbleScan."); }); - events.addEventListener("denied", () => { setMessage("Not allowed."); events?.close(); }); - events.addEventListener("expired", () => { setMessage("Timed out. Scan again."); events?.close(); }); + events.addEventListener("denied", () => { + setMessage("Not allowed."); + events?.close(); + }); + events.addEventListener("expired", () => { + setMessage("Timed out. Scan again."); + events?.close(); + }); } catch { if (!cancelled) setMessage("Couldn't connect. Check your connection and scan a new code."); } }; void connect(); - return () => { cancelled = true; events?.close(); }; + return () => { + cancelled = true; + events?.close(); + }; }, []); const sendFile = async (file: File, id: number) => { @@ -73,17 +90,21 @@ export default function ScanPage() { let status = 0; for (const wait of RETRY_WAITS_MS) { if (wait) { - setUploads((current) => current.map((item) => item.id === id ? { ...item, status: "Retrying…" } : item)); + setUploads((current) => + current.map((item) => (item.id === id ? { ...item, status: "Retrying…" } : item)), + ); await new Promise((resolve) => setTimeout(resolve, wait)); } const body = new FormData(); body.append("file", file); try { - status = (await fetch(`/v1/spaces/${grant.space_id}/documents`, { - method: "POST", - body, - headers: { Authorization: `Bearer ${grant.token}`, "Idempotency-Key": key }, - })).status; + status = ( + await fetch(`/v1/spaces/${grant.space_id}/documents`, { + method: "POST", + body, + headers: { Authorization: `Bearer ${grant.token}`, "Idempotency-Key": key }, + }) + ).status; } catch { status = 0; continue; @@ -91,7 +112,13 @@ export default function ScanPage() { if (status < 500) break; } const ok = status >= 200 && status < 300; - setUploads((current) => current.map((item) => item.id === id ? { ...item, status: ok ? "Sent" : status === 401 ? "Disconnected" : "Failed", failed: !ok } : item)); + setUploads((current) => + current.map((item) => + item.id === id + ? { ...item, status: ok ? "Sent" : status === 401 ? "Disconnected" : "Failed", failed: !ok } + : item, + ), + ); }; const onFiles = (files: FileList | null) => { @@ -107,13 +134,36 @@ export default function ScanPage() { return (

ScribbleScan camera

-

{message}

+

+ {message} +

{grant ? ( <> - onFiles(event.currentTarget.files)} /> - + onFiles(event.currentTarget.files)} + /> +
    - {uploads.map((upload) =>
  • {upload.name}{upload.status}
  • )} + {uploads.map((upload) => ( +
  • + {upload.name} + {upload.status} +
  • + ))}
) : null} diff --git a/app/spaces/new/page.tsx b/app/spaces/new/page.tsx index 9a5d0bb..76292f5 100644 --- a/app/spaces/new/page.tsx +++ b/app/spaces/new/page.tsx @@ -15,36 +15,80 @@ export default function NewSpacePage() { return (
- Back to spaces + + Back to spaces +

Create a space

Give a set of spending its own name.

-
{ - event.preventDefault(); - const cleanName = name.trim(); - if (!cleanName) { setError("Enter a name for this space."); return; } - setSaving(true); - try { - await addSpace(cleanName, kind); - router.push("/spaces"); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Couldn't create the space."); - setSaving(false); - } - }} className="rounded-2xl border border-line bg-card p-5 sm:p-7"> - - { setName(event.target.value); setError(""); }} placeholder="For example, Family or Shop" className="mt-2 w-full rounded-lg border border-line bg-paper px-3.5 py-3 outline-none focus:border-ledger" /> - - { + setName(event.target.value); + setError(""); + }} + placeholder="For example, Family or Shop" + className="mt-2 w-full rounded-lg border border-line bg-paper px-3.5 py-3 outline-none focus:border-ledger" + /> + + -

{error}

+

+ {error} +

- - Cancel + + + Cancel +
-

In sample mode, this space exists until you reload the page.

+

+ In sample mode, this space exists until you reload the page. +

); diff --git a/app/spaces/page.tsx b/app/spaces/page.tsx index 9713453..c77f090 100644 --- a/app/spaces/page.tsx +++ b/app/spaces/page.tsx @@ -13,25 +13,68 @@ export default function SpacesPage() {

Spaces

Choose a space for home, a shop, or another set of spending.

- Create a space + + Create a space +
{spaces.map((space) => ( -
+
- +
- {space.name === activeSpace ? Current space : null} + {space.name === activeSpace ? ( + + Current space + + ) : null}

{space.name}

-

{space.kind === "personal" ? "Personal spending" : "Business spending and budgets"}

- {space.name !== activeSpace ? : null} +

+ {space.kind === "personal" ? "Personal spending" : "Business spending and budgets"} +

+ {space.name !== activeSpace ? ( + + ) : null}
))}
- {source === "sample" ?

Sample mode: spaces are temporary and will reset when you reload.

: null} + {source === "sample" ? ( +

+ Sample mode: spaces are temporary and will reset when you reload. +

+ ) : null}
); } diff --git a/biome.json b/biome.json index b0d897a..bfdcade 100644 --- a/biome.json +++ b/biome.json @@ -22,5 +22,10 @@ "organizeImports": "on" } } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } } diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 21eabfc..300a84c 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -2,15 +2,15 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; -import { DemoProvider, useDemo } from "@/src/components/demo-context"; +import { useCallback, useEffect, useState } from "react"; import { api, clearTokens, DEMO_ACCOUNT_EMAIL, isDemoSession, oidcConfig, session } from "@/src/api/client"; import type { NotificationsOut } from "@/src/api/types"; +import { DemoProvider, useDemo } from "@/src/components/demo-context"; +import { type AccountNotification, ProfileMenu } from "@/src/components/profile-menu"; +import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; import * as oidc from "@/src/oidc"; import { store } from "@/src/store"; -import { ProfileMenu, type AccountNotification } from "@/src/components/profile-menu"; -import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; type NavItem = { href?: string; label: string; icon: "spending" | "receipts" | "add" | "budgets" | "spaces" }; @@ -32,7 +32,16 @@ function NavIcon({ name }: { name: NavItem["icon"] }) { }; return ( - {item.label}; - return ( - item.href - ? {content} - : + const content = ( + <> + + {item.label} + + ); + return item.href ? ( + + {content} + + ) : ( + ); })} @@ -101,9 +130,23 @@ export function AppShell({ children }: { children: ReactNode }) { }; }, [router]); - if (isPublicRoute) return {children}; - if (!checked || !authorized) return
Taking you to sign in…
; - return {children}; + if (isPublicRoute) + return ( + + {children} + + ); + if (!checked || !authorized) + return ( +
+ Taking you to sign in… +
+ ); + return ( + + {children} + + ); } function AppFrame({ children }: { children: ReactNode }) { @@ -114,6 +157,7 @@ function AppFrame({ children }: { children: ReactNode }) { const openUpload = useCallback(() => setUploadOpen(true), []); const closeUpload = useCallback(() => setUploadOpen(false), []); + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname refreshes notifications on every page change useEffect(() => { if (source !== "connected") { setServerNotifications([]); @@ -167,43 +211,99 @@ function AppFrame({ children }: { children: ReactNode }) { else window.location.assign("/"); }; if (loading && pathname !== "/connect" && pathname !== "/scan") { - return
Loading your account…
; + return ( +
+ Loading your account… +
+ ); } const notifications: AccountNotification[] = source === "connected" ? serverNotifications : []; return (
- S - - selectSpace(event.target.value)} + className="max-w-[44vw] appearance-none rounded-full border border-line bg-card px-3 py-2 pr-8 text-sm font-semibold text-ink outline-none focus:border-ledger sm:max-w-[58vw] sm:px-4" + > + {spaces.map((space) => ( + + ))} - {source === "connected" ? "Connected" : "Sample data"} + + {source === "connected" ? "Connected" : "Sample data"} +
- - void markServerNotificationsRead() : undefined} onDownloadCsv={downloadCsv} onSignOut={() => void signOut()} /> + void markServerNotificationsRead() : undefined + } + onDownloadCsv={downloadCsv} + onSignOut={() => void signOut()} + />
- {source === "sample" ?
Preview data. Live account data is unavailable.
: null} + {source === "sample" ? ( +
+ Preview data. Live account data is + unavailable. +
+ ) : null} {children}
diff --git a/src/components/budget-arc.tsx b/src/components/budget-arc.tsx index 4774912..62aa028 100644 --- a/src/components/budget-arc.tsx +++ b/src/components/budget-arc.tsx @@ -11,31 +11,86 @@ export function BudgetArc({ categories, currency }: { categories: Category[]; cu

Category budget use

- {featured.length} categories + + {featured.length} categories +
- `${category.name}: ${category.budget ? Math.round(category.amount / category.budget * 100) : 0}% of budget`).join(", ")}> + + `${category.name}: ${category.budget ? Math.round((category.amount / category.budget) * 100) : 0}% of budget`, + ) + .join(", ")} + > {featured.map((category, index) => { const radius = 135 - index * 23; - const progress = category.budget ? Math.min(100, Math.max(0, category.amount / category.budget * 100)) : 0; + const progress = category.budget + ? Math.min(100, Math.max(0, (category.amount / category.budget) * 100)) + : 0; const arc = `M ${160 - radius} 160 A ${radius} ${radius} 0 0 1 ${160 + radius} 160`; - return - - - ; + return ( + + + + + ); })} - {overall}% - overall + + {overall}% + + + overall +
{featured.map((category) => { - const percent = category.budget ? Math.round(category.amount / category.budget * 100) : 0; - return
-
- - {category.name} + const percent = category.budget ? Math.round((category.amount / category.budget) * 100) : 0; + return ( +
+
+ + {category.name} +
+

+ {money(category.amount, currency)}{" "} + · {percent}% +

-

{money(category.amount, currency)} · {percent}%

-
; + ); })}
diff --git a/src/components/daily-spend-heatmap.tsx b/src/components/daily-spend-heatmap.tsx index 19bf56d..83be5a2 100644 --- a/src/components/daily-spend-heatmap.tsx +++ b/src/components/daily-spend-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { money, type Category, type Receipt } from "@/src/lib/demo-data"; +import { type Category, money, type Receipt } from "@/src/lib/demo-data"; const intensityClasses = [ "bg-[var(--track)] text-quiet", @@ -13,7 +13,17 @@ const intensityClasses = [ type DailyCategory = { name: string; amount: number; color: string }; -export function DailySpendHeatmap({ receipts, categories, currency, month }: { receipts: Receipt[]; categories: Category[]; currency: string; month: string }) { +export function DailySpendHeatmap({ + receipts, + categories, + currency, + month, +}: { + receipts: Receipt[]; + categories: Category[]; + currency: string; + month: string; +}) { const [selectedDate, setSelectedDate] = useState(null); const [year, monthNumber] = month.split("-").map(Number); const monthIndex = monthNumber - 1; @@ -36,10 +46,15 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r day.categories.set(key, category); totals.set(receipt.date, day); } - return new Map([...totals].map(([date, day]) => [date, { - total: day.total, - categories: [...day.categories.values()].sort((a, b) => a.name.localeCompare(b.name)), - }])); + return new Map( + [...totals].map(([date, day]) => [ + date, + { + total: day.total, + categories: [...day.categories.values()].sort((a, b) => a.name.localeCompare(b.name)), + }, + ]), + ); }, [categories, month, receipts]); const maxAmount = Math.max(0, ...[...spendingByDay.values()].map((day) => day.total)); @@ -47,33 +62,42 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r for (const day of spendingByDay.values()) { for (const category of day.categories) monthCategories.set(category.name.toLowerCase(), category); } - const latestSpendDate = [...spendingByDay.entries()] - .filter(([, day]) => day.total > 0) - .map(([date]) => date) - .sort() - .at(-1) ?? null; + const latestSpendDate = + [...spendingByDay.entries()] + .filter(([, day]) => day.total > 0) + .map(([date]) => date) + .sort() + .at(-1) ?? null; const activeDate = selectedDate && spendingByDay.has(selectedDate) ? selectedDate : latestSpendDate; - const activeAmount = activeDate ? spendingByDay.get(activeDate)?.total ?? 0 : 0; + const activeAmount = activeDate ? (spendingByDay.get(activeDate)?.total ?? 0) : 0; const activeLabel = activeDate - ? new Date(year, monthIndex, Number(activeDate.slice(-2))).toLocaleDateString("en-GB", { day: "numeric", month: "short" }) + ? new Date(year, monthIndex, Number(activeDate.slice(-2))).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + }) : null; return ( -
+

Daily spending

-

{activeLabel ? `${activeLabel} · ${money(activeAmount, currency)}` : "No receipt spending recorded"}

+

+ {activeLabel + ? `${activeLabel} · ${money(activeAmount, currency)}` + : "No receipt spending recorded"} +

-
+
-
{Array.from({ length: daysInMonth }, (_, index) => { @@ -81,14 +105,28 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r const isoDate = `${month}-${String(day).padStart(2, "0")}`; const dailySpend = spendingByDay.get(isoDate); const amount = dailySpend?.total ?? 0; - const categoryDetails = dailySpend?.categories.map((category) => `${category.name}: ${money(category.amount, currency)}`).join(", "); - const level = amount > 0 && maxAmount > 0 - ? amount >= maxAmount * 0.75 ? 4 : amount >= maxAmount * 0.5 ? 3 : amount >= maxAmount * 0.25 ? 2 : 1 - : 0; - const label = new Date(year, monthIndex, day).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }); - const spendDetail = amount > 0 - ? `${money(amount, currency)} spent${categoryDetails ? `. Categories: ${categoryDetails}` : ""}` - : "no receipt spending"; + const categoryDetails = dailySpend?.categories + .map((category) => `${category.name}: ${money(category.amount, currency)}`) + .join(", "); + const level = + amount > 0 && maxAmount > 0 + ? amount >= maxAmount * 0.75 + ? 4 + : amount >= maxAmount * 0.5 + ? 3 + : amount >= maxAmount * 0.25 + ? 2 + : 1 + : 0; + const label = new Date(year, monthIndex, day).toLocaleDateString("en-GB", { + weekday: "long", + day: "numeric", + month: "long", + }); + const spendDetail = + amount > 0 + ? `${money(amount, currency)} spent${categoryDetails ? `. Categories: ${categoryDetails}` : ""}` + : "no receipt spending"; return ( ); })} -
- {monthCategories.size ?
- {[...monthCategories.values()].sort((a, b) => a.name.localeCompare(b.name)).map((category) => ( - - - ))} -
: null} -
+ + {monthCategories.size ? ( +
+ {[...monthCategories.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((category) => ( + + + ))} +
+ ) : null} +
); } diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index b607e55..7e53b1f 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -1,29 +1,55 @@ "use client"; import Link from "next/link"; -import { dateLabel, money } from "@/src/lib/demo-data"; -import { useDemo } from "@/src/components/demo-context"; -import { MonthNav } from "@/src/components/month-nav"; import { BudgetArc } from "@/src/components/budget-arc"; import { DailySpendHeatmap } from "@/src/components/daily-spend-heatmap"; +import { useDemo } from "@/src/components/demo-context"; +import { MonthNav } from "@/src/components/month-nav"; +import { dateLabel, money } from "@/src/lib/demo-data"; -function Metric({ label, value, note, accent = false }: { label: string; value: string; note: string; accent?: boolean }) { +function Metric({ + label, + value, + note, + accent = false, +}: { + label: string; + value: string; + note: string; + accent?: boolean; +}) { return (

{label}

-

{value}

+

+ {value} +

{note}

); } export function Dashboard() { - const { activeSpace, categories, receipts, spentTotal, budgetTotal, documentCount, lastMonthTotal, changePct, currency, month } = useDemo(); + const { + activeSpace, + categories, + receipts, + spentTotal, + budgetTotal, + documentCount, + lastMonthTotal, + changePct, + currency, + month, + } = useDemo(); const percent = budgetTotal ? Math.round((spentTotal / budgetTotal) * 100) : 0; const remaining = Math.max(0, budgetTotal - spentTotal); - const comparison = changePct == null - ? `${documentCount} receipts recorded this month` - : `${Math.abs(changePct)}% ${changePct > 0 ? "more" : "less"} than last month (${money(lastMonthTotal, currency)})`; + const comparison = + changePct == null + ? `${documentCount} receipts recorded this month` + : `${Math.abs(changePct)}% ${changePct > 0 ? "more" : "less"} than last month (${money(lastMonthTotal, currency)})`; return (
@@ -36,11 +62,25 @@ export function Dashboard() {
- - + +
- +
@@ -51,18 +91,32 @@ export function Dashboard() {

Recent receipts

Latest additions to {activeSpace}.

- See all + + See all +
{receipts.slice(0, 8).map((receipt) => ( - + {receipt.merchant} - {receipt.category} · {dateLabel(receipt.date)} + + {receipt.category} · {dateLabel(receipt.date)} + - {money(receipt.amount, currency)} - {receipt.status} + + {money(receipt.amount, currency)} + + + {receipt.status} + ))} diff --git a/src/components/demo-context.tsx b/src/components/demo-context.tsx index f83cd22..d15575b 100644 --- a/src/components/demo-context.tsx +++ b/src/components/demo-context.tsx @@ -1,11 +1,18 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { api, isDemoSession, session } from "@/src/api/client"; import type { ClientConfig, DocumentOut, Expenses, SpaceOut, Summary } from "@/src/api/types"; import { thisMonth, title } from "@/src/format"; +import { + type Category, + type Receipt, + budgetTotal as sampleBudget, + categories as sampleCategories, + receipts as sampleReceipts, + spentTotal as sampleSpent, +} from "@/src/lib/demo-data"; import { store } from "@/src/store"; -import { categories as sampleCategories, receipts as sampleReceipts, spentTotal as sampleSpent, budgetTotal as sampleBudget, type Category, type Receipt } from "@/src/lib/demo-data"; type Space = Pick; type DataSource = "sample" | "connected"; @@ -35,14 +42,26 @@ type AppData = { upload: (spaceId: string, file: File) => Promise; }; -const colorByCategory: Record = Object.fromEntries(sampleCategories.map((category) => [category.name.toLowerCase(), category.color])); +const colorByCategory: Record = Object.fromEntries( + sampleCategories.map((category) => [category.name.toLowerCase(), category.color]), +); const sampleSpaces: Space[] = [ { id: "sample-personal", name: "Personal", kind: "personal", currency: "BDT" }, { id: "sample-shop", name: "Demo shop", kind: "business", currency: "BDT" }, ]; -type DataState = Omit; +type DataState = Omit< + AppData, + | "selectSpace" + | "setMonth" + | "addSpace" + | "saveBudgets" + | "updateReceipt" + | "downloadCsv" + | "refresh" + | "upload" +>; const initialData: DataState = { source: "sample" as DataSource, @@ -144,41 +163,73 @@ export function DemoProvider({ children }: { children: ReactNode }) { void loadLive(); }, [loadLive]); - const selectSpace = useCallback((name: string) => { - setData((current) => ({ ...current, activeSpace: name })); - if (data.source === "connected") void loadLive(name, data.month); - }, [data.month, data.source, loadLive]); + const selectSpace = useCallback( + (name: string) => { + setData((current) => ({ ...current, activeSpace: name })); + if (data.source === "connected") void loadLive(name, data.month); + }, + [data.month, data.source, loadLive], + ); - const setMonth = useCallback((month: string) => { - setData((current) => ({ ...current, month })); - if (data.source === "connected") void loadLive(data.activeSpace, month); - }, [data.activeSpace, data.source, loadLive]); + const setMonth = useCallback( + (month: string) => { + setData((current) => ({ ...current, month })); + if (data.source === "connected") void loadLive(data.activeSpace, month); + }, + [data.activeSpace, data.source, loadLive], + ); - const addSpace = useCallback(async (name: string, kind: "personal" | "business" = "business") => { - if (data.source === "connected") { - await api("/v1/spaces", { method: "POST", body: { name, kind, currency: data.currency } }); - await loadLive(name, data.month); - return; - } - const space: Space = { id: `sample-${crypto.randomUUID()}`, name, kind, currency: "BDT" }; - setData((current) => ({ ...current, spaces: [...current.spaces, space], activeSpace: name, activeSpaceId: space.id })); - }, [data.currency, data.month, data.source, loadLive]); - - const saveBudgets = useCallback(async (budgets: Record) => { - if (data.source === "connected") { - await api(`/v1/spaces/${data.activeSpaceId}/budgets`, { method: "PUT", body: { budgets } }); - await loadLive(data.activeSpace, data.month); - return; - } - setData((current) => { - const categories = current.categories.map((category) => ({ ...category, budget: budgets[category.key] ?? 0 })); - return { ...current, categories, budgetTotal: categories.reduce((sum, category) => sum + category.budget, 0) }; - }); - }, [data.activeSpace, data.activeSpaceId, data.month, data.source, loadLive]); - - const updateReceipt = useCallback((id: string, updates: Partial>) => { - setData((current) => ({ ...current, receipts: current.receipts.map((receipt) => receipt.id === id ? { ...receipt, ...updates } : receipt) })); - }, []); + const addSpace = useCallback( + async (name: string, kind: "personal" | "business" = "business") => { + if (data.source === "connected") { + await api("/v1/spaces", { method: "POST", body: { name, kind, currency: data.currency } }); + await loadLive(name, data.month); + return; + } + const space: Space = { id: `sample-${crypto.randomUUID()}`, name, kind, currency: "BDT" }; + setData((current) => ({ + ...current, + spaces: [...current.spaces, space], + activeSpace: name, + activeSpaceId: space.id, + })); + }, + [data.currency, data.month, data.source, loadLive], + ); + + const saveBudgets = useCallback( + async (budgets: Record) => { + if (data.source === "connected") { + await api(`/v1/spaces/${data.activeSpaceId}/budgets`, { method: "PUT", body: { budgets } }); + await loadLive(data.activeSpace, data.month); + return; + } + setData((current) => { + const categories = current.categories.map((category) => ({ + ...category, + budget: budgets[category.key] ?? 0, + })); + return { + ...current, + categories, + budgetTotal: categories.reduce((sum, category) => sum + category.budget, 0), + }; + }); + }, + [data.activeSpace, data.activeSpaceId, data.month, data.source, loadLive], + ); + + const updateReceipt = useCallback( + (id: string, updates: Partial>) => { + setData((current) => ({ + ...current, + receipts: current.receipts.map((receipt) => + receipt.id === id ? { ...receipt, ...updates } : receipt, + ), + })); + }, + [], + ); const upload = useCallback(async (spaceId: string, file: File) => { const { uploadFile } = await import("@/src/api/client"); @@ -188,13 +239,25 @@ export function DemoProvider({ children }: { children: ReactNode }) { const downloadCsv = useCallback(() => { const rows: (string | number)[][] = [ ["Date", "Merchant", "Category", "Amount", "Status"], - ...data.receipts.map((receipt) => [receipt.date, receipt.merchant, receipt.category, receipt.amount, receipt.status]), + ...data.receipts.map((receipt) => [ + receipt.date, + receipt.merchant, + receipt.category, + receipt.amount, + receipt.status, + ]), ]; - const csv = rows.map((row) => row.map((value) => { - const raw = String(value); - const safe = /^[=+@\t\r]/.test(raw) ? `'${raw}` : raw; - return `"${safe.replaceAll('"', '""')}"`; - }).join(",")).join("\r\n"); + const csv = rows + .map((row) => + row + .map((value) => { + const raw = String(value); + const safe = /^[=+@\t\r]/.test(raw) ? `'${raw}` : raw; + return `"${safe.replaceAll('"', '""')}"`; + }) + .join(","), + ) + .join("\r\n"); const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -206,17 +269,20 @@ export function DemoProvider({ children }: { children: ReactNode }) { window.setTimeout(() => URL.revokeObjectURL(url), 2000); }, [data.activeSpace, data.month, data.receipts]); - const value = useMemo(() => ({ - ...data, - selectSpace, - setMonth, - addSpace, - saveBudgets, - updateReceipt, - downloadCsv, - refresh: () => loadLive(data.activeSpace, data.month), - upload, - }), [data, selectSpace, setMonth, addSpace, saveBudgets, updateReceipt, downloadCsv, loadLive, upload]); + const value = useMemo( + () => ({ + ...data, + selectSpace, + setMonth, + addSpace, + saveBudgets, + updateReceipt, + downloadCsv, + refresh: () => loadLive(data.activeSpace, data.month), + upload, + }), + [data, selectSpace, setMonth, addSpace, saveBudgets, updateReceipt, downloadCsv, loadLive, upload], + ); return {children}; } diff --git a/src/components/month-nav.tsx b/src/components/month-nav.tsx index 7346b4e..dd5976f 100644 --- a/src/components/month-nav.tsx +++ b/src/components/month-nav.tsx @@ -1,19 +1,49 @@ "use client"; -import { monthLabel, shiftMonth, thisMonth } from "@/src/format"; import { useDemo } from "@/src/components/demo-context"; +import { monthLabel, shiftMonth, thisMonth } from "@/src/format"; export function MonthNav() { const { month, source, setMonth } = useDemo(); const move = (amount: number) => setMonth(shiftMonth(month, amount)); return (
- {monthLabel(month)} -
); diff --git a/src/components/profile-menu.tsx b/src/components/profile-menu.tsx index af2beb3..b8982a4 100644 --- a/src/components/profile-menu.tsx +++ b/src/components/profile-menu.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; import { store } from "@/src/store"; type Theme = "dark" | "light"; @@ -18,24 +18,99 @@ type ProfileMenuProps = { onSignOut: () => void; }; -export type AccountNotification = { id: string; title: string; detail: string; href: string; unread?: boolean }; +export type AccountNotification = { + id: string; + title: string; + detail: string; + href: string; + unread?: boolean; +}; -function Icon({ name }: { name: "user" | "settings" | "theme" | "subscription" | "notification" | "csv" | "back" | "logout" }) { +function Icon({ + name, +}: { + name: "user" | "settings" | "theme" | "subscription" | "notification" | "csv" | "back" | "logout"; +}) { const paths = { - user: <>, - settings: <>, - theme: <>, - subscription: <>, - notification: <>, - csv: <>, - back: <>, - logout: <>, + user: ( + <> + + + + ), + settings: ( + <> + + + + ), + theme: ( + <> + + + + ), + subscription: ( + <> + + + + ), + notification: ( + <> + + + + ), + csv: ( + <> + + + + ), + back: ( + <> + + + + ), + logout: ( + <> + + + + ), }; - return ; + return ( + + ); } -export function ProfileMenu({ email, preview, space, currency, notifications, onNotificationsOpened, onDownloadCsv, onSignOut }: ProfileMenuProps) { +export function ProfileMenu({ + email, + preview, + space, + currency, + notifications, + onNotificationsOpened, + onDownloadCsv, + onSignOut, +}: ProfileMenuProps) { const root = useRef(null); const trigger = useRef(null); const [open, setOpen] = useState(false); @@ -60,7 +135,9 @@ export function ProfileMenu({ email, preview, space, currency, notifications, on useEffect(() => { try { const stored = JSON.parse(store.get("ss.seen-notifications") ?? "[]") as unknown; - setSeenNotificationIds(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === "string") : []); + setSeenNotificationIds( + Array.isArray(stored) ? stored.filter((id): id is string => typeof id === "string") : [], + ); } catch { setSeenNotificationIds([]); } @@ -114,11 +191,18 @@ export function ProfileMenu({ email, preview, space, currency, notifications, on }; const showPanel = (nextPanel: Panel) => setPanel(nextPanel); - const heading = panel === "settings" ? "Settings" : panel === "appearance" ? "Change theme" : "Subscription"; + const heading = + panel === "settings" ? "Settings" : panel === "appearance" ? "Change theme" : "Subscription"; return (
- + - {unseenCount > 0 ? : null} + {unseenCount > 0 ? ( + + ) : null} {open ? ( -