Files
butcem/src/features/transactions/components/statement-view.tsx
T

388 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ArrowLeft,
CheckCheck,
ChevronLeft,
ChevronRight,
List,
Loader2,
Scale,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { PageHeader } from "@/components/page-header";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { todayIstanbul } from "@/shared/dates";
import { formatDate, formatMoney } from "@/shared/format";
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
const t = tr.statement;
const r = t.reconcile;
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
});
const monthLabel = (ym: string) => monthFmt.format(new Date(`${ym}-15T12:00:00+03:00`));
const dateLabel = (ymd: string) => formatDate(new Date(`${ymd}T12:00:00+03:00`));
function SignedMoney({ minor, className }: { minor: number; className?: string }) {
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
minor > 0 && "text-income",
minor < 0 && "text-expense",
className,
)}
>
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
{formatMoney(Math.abs(minor))}
</span>
);
}
export function StatementView({
statement: s,
canWrite,
}: {
statement: AccountStatement;
canWrite: boolean;
}) {
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
const [reconcileOpen, setReconcileOpen] = useState(false);
const base = `/hesaplar/${s.account.id}`;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/hesaplar">
<ArrowLeft />
{t.back}
</Link>
</Button>
<PageHeader
title={s.account.name}
description={`${tr.accounts.types[s.account.type]} · ${t.current}: ${formatMoney(s.currentBalanceMinor)}`}
actions={
<>
<Button variant="outline" asChild>
<Link href={`/islemler?hesap=${s.account.id}`}>
<List />
<span className="hidden sm:inline">{t.allTransactions}</span>
</Link>
</Button>
{canWrite && s.canReconcile ? (
<Button onClick={() => setReconcileOpen(true)}>
<Scale />
{r.open}
</Button>
) : null}
</>
}
/>
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
<Link href={`${base}?ay=${s.prevMonth}`}>
<ChevronLeft />
</Link>
</Button>
<h2 className="text-base font-medium capitalize">{monthLabel(s.month)}</h2>
{s.nextMonth ? (
<Button variant="outline" size="icon-sm" asChild aria-label={t.nextMonth}>
<Link href={`${base}?ay=${s.nextMonth}`}>
<ChevronRight />
</Link>
</Button>
) : (
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
<ChevronRight />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{(
[
[t.opening, s.openingMinor, false],
[t.inflow, s.inflowMinor, true],
[t.outflow, -s.outflowMinor, true],
[t.closing, s.closingMinor, false],
] as const
).map(([label, value, colored]) => (
<Card key={label} size="sm">
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-base">
{colored ? (
<SignedMoney minor={value} />
) : (
<span className="tabular-nums">{formatMoney(value)}</span>
)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CheckCheck className="size-3.5" aria-hidden />
{s.lastReconciliation
? t.lastReconciliation(
dateLabel(s.lastReconciliation.date),
formatMoney(s.lastReconciliation.statementBalanceMinor),
)
: t.noReconciliation}
</p>
{s.rows.length === 0 ? (
<Card>
<CardHeader>
<CardDescription>{t.empty}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">
{s.rows.map((row) => (
<li key={row.item.id}>
<StatementRowView row={row} onOpen={() => openDetail(row.item.id)} />
</li>
))}
</ul>
)}
{s.truncated ? <p className="text-xs text-muted-foreground">{t.truncated}</p> : null}
{detailElement}
<ReconcileDialog
open={reconcileOpen}
onOpenChange={setReconcileOpen}
accountId={s.account.id}
isLiability={s.account.isLiability}
/>
</div>
);
}
function StatementRowView({ row, onOpen }: { row: StatementRow; onOpen: () => void }) {
const content = (
<>
<RowIcon item={row.item} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{rowTitle(row.item)}</span>
{row.reconciled ? (
<CheckCheck className="size-3.5 shrink-0 text-income" aria-label={t.reconciledHint} />
) : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{dateLabel(row.item.date)} · {rowSubtitle(row.item)}
</span>
</span>
<span className="flex flex-col items-end">
<SignedMoney minor={row.deltaMinor} />
<span className="text-xs text-muted-foreground tabular-nums" title={t.balanceAfter}>
{formatMoney(row.balanceMinor)}
</span>
</span>
</>
);
const cls = "flex w-full items-center gap-3 p-3 text-left";
// Gizli karşı hesaba dokunan işlemin ayrıntısı açılmaz.
return row.restricted ? (
<div className={cls}>{content}</div>
) : (
<button
type="button"
onClick={onOpen}
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
>
{content}
</button>
);
}
function ReconcileDialog({
open,
onOpenChange,
accountId,
isLiability,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string;
isLiability: boolean;
}) {
const [date, setDate] = useState(todayIstanbul);
const [balance, setBalance] = useState("");
const [preview, setPreview] = useState<ReconcilePreview | null>(null);
const [accept, setAccept] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [pending, setPending] = useState(false);
function reset(next: boolean) {
if (!next) {
setPreview(null);
setAccept(false);
setErrors({});
setBalance("");
setDate(todayIstanbul());
}
onOpenChange(next);
}
const input = () => ({
statementDate: date,
statementBalance: balance,
acceptAdjustment: accept,
});
async function check() {
setPending(true);
const res = await previewReconciliationAction(accountId, input());
setPending(false);
if (!res.ok) {
setErrors({ ...res.fieldErrors, root: res.fieldErrors ? undefined : res.error });
return;
}
setErrors({});
setAccept(false);
setPreview(res.data);
}
async function confirm() {
setPending(true);
const res = await reconcileAccountAction(accountId, input());
setPending(false);
if (!res.ok) {
setErrors({ root: res.error });
return;
}
toast.success(
res.data.adjustmentId
? r.doneWithAdjustment(res.data.markedCount)
: r.done(res.data.markedCount),
);
reset(false);
}
const diff = preview?.differenceMinor ?? 0;
const display = (minor: number) => formatMoney(isLiability ? -minor : minor);
return (
<ResponsiveDialog
open={open}
onOpenChange={reset}
title={r.title}
description={r.description}
footer={
<>
<Button variant="outline" type="button" onClick={() => reset(false)}>
{tr.common.cancel}
</Button>
{preview ? (
<Button onClick={confirm} disabled={pending || (diff !== 0 && !accept)}>
{pending ? <Loader2 className="animate-spin" /> : null}
{diff !== 0 ? r.confirmWithAdjustment : r.confirm}
</Button>
) : (
<Button onClick={check} disabled={pending || !balance.trim()}>
{pending ? <Loader2 className="animate-spin" /> : null}
{r.check}
</Button>
)}
</>
}
>
<FieldGroup>
<Field data-invalid={Boolean(errors.statementDate)}>
<FieldLabel htmlFor="rec-date">{r.date}</FieldLabel>
<Input
id="rec-date"
type="date"
value={date}
max={todayIstanbul()}
onChange={(e) => {
setDate(e.target.value);
setPreview(null);
}}
/>
{errors.statementDate ? <FieldError>{errors.statementDate}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.statementBalance)}>
<FieldLabel htmlFor="rec-balance">{isLiability ? r.debt : r.balance}</FieldLabel>
<MoneyInput
id="rec-balance"
value={balance}
allowNegative
onChange={(v) => {
setBalance(v);
setPreview(null);
}}
/>
<FieldDescription>{isLiability ? r.debtHint : r.balanceHint}</FieldDescription>
{errors.statementBalance ? <FieldError>{errors.statementBalance}</FieldError> : null}
</Field>
{preview ? (
<div className="flex flex-col gap-3 rounded-xl border p-3" aria-live="polite">
<dl className="grid grid-cols-3 gap-2 text-sm">
<div>
<dt className="text-xs text-muted-foreground">{r.statementBalance}</dt>
<dd className="tabular-nums">{display(preview.statementBalanceMinor)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{r.appBalance}</dt>
<dd className="tabular-nums">{display(preview.appBalanceMinor)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{r.difference}</dt>
<dd>
<SignedMoney minor={diff} />
</dd>
</div>
</dl>
{diff === 0 ? (
<p className="text-sm text-income">{r.matches}</p>
) : (
<>
<p className="text-sm text-expense">{r.mismatch}</p>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={accept}
onCheckedChange={(v) => setAccept(v === true)}
className="mt-0.5"
/>
<span>
{r.adjustmentOffer(
formatMoney(Math.abs(diff)),
diff > 0 ? tr.transactions.types.income : tr.transactions.types.expense,
)}
</span>
</label>
</>
)}
<Badge variant="secondary" className="w-fit">
{r.unreconciled(preview.unreconciledCount)}
</Badge>
</div>
) : null}
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</ResponsiveDialog>
);
}