103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import Link from "next/link";
|
||
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
|
||
import { Card } from "@/ds";
|
||
import { tugrik } from "@/ds/money";
|
||
import { useTransactions } from "@/api/hooks/reads";
|
||
import type { Txn } from "@/api/schemas";
|
||
import { plannerStrings as s } from "./strings";
|
||
|
||
export interface CategoryTransactionsProps {
|
||
category: string;
|
||
}
|
||
|
||
/** MM.dd from an RFC3339/ISO timestamp — mirrors CategoryTransactionsView's
|
||
* `shortDate` on iOS. Falls back to an empty string on unparsable input. */
|
||
function shortDate(rfc: string): string {
|
||
const d = new Date(rfc);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
return `${mm}.${dd}`;
|
||
}
|
||
|
||
/** Every transaction in one category (server expands to sub-categories too) —
|
||
* opened by tapping a category-limit row on the planner hub. Ports
|
||
* `CategoryTransactionsView.swift`: header + filter + list, tap-free (the web
|
||
* port has no transaction detail cover yet). */
|
||
export function CategoryTransactions({ category }: CategoryTransactionsProps) {
|
||
const { data, isLoading } = useTransactions({
|
||
category,
|
||
from: "2020-01-01",
|
||
to: "2027-12-31",
|
||
limit: 300,
|
||
});
|
||
const [search, setSearch] = React.useState("");
|
||
|
||
const rows: Txn[] = React.useMemo(() => {
|
||
const all = data ?? [];
|
||
const needle = search.trim().toLowerCase();
|
||
if (!needle) return all;
|
||
return all.filter((t) => t.title.toLowerCase().includes(needle));
|
||
}, [data, search]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<Link href="/planner" aria-label={s.amountEntry.cancel} style={{ color: "var(--seed-color-fg-neutral)" }}>
|
||
←
|
||
</Link>
|
||
<h1 style={{ fontWeight: 700, fontSize: 16 }}>{category}</h1>
|
||
</div>
|
||
|
||
<TextFieldRoot value={search} onValueChange={setSearch}>
|
||
<TextFieldInput
|
||
placeholder={s.categoryTransactions.filterPlaceholder}
|
||
aria-label={s.categoryTransactions.filterPlaceholder}
|
||
/>
|
||
</TextFieldRoot>
|
||
|
||
<Card>
|
||
{isLoading && <p style={{ color: "var(--seed-color-fg-placeholder)" }}>…</p>}
|
||
{!isLoading && rows.length === 0 && (
|
||
<p style={{ color: "var(--seed-color-fg-placeholder)" }}>{s.categoryTransactions.empty}</p>
|
||
)}
|
||
{!isLoading && rows.length > 0 && (
|
||
<ul className="flex flex-col gap-3">
|
||
{rows.map((t, i) => {
|
||
const income = t.direction === "income";
|
||
return (
|
||
<li
|
||
key={`${t.txnId ?? i}`}
|
||
className="flex items-center justify-between gap-3"
|
||
style={{
|
||
borderBottom: i < rows.length - 1 ? "1px solid var(--seed-color-border-default, #eee)" : undefined,
|
||
paddingBottom: 12,
|
||
}}
|
||
>
|
||
<div className="flex flex-col">
|
||
<span style={{ fontWeight: 700 }}>{t.title || t.category}</span>
|
||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
|
||
{shortDate(t.date)} · {t.category}
|
||
</span>
|
||
</div>
|
||
<span
|
||
style={{
|
||
fontWeight: 700,
|
||
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||
}}
|
||
>
|
||
{income ? "+" : "−"}
|
||
{tugrik(t.amount)}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|