8a9f5ddc7d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Archive,
|
||
ArchiveRestore,
|
||
ArrowDown,
|
||
ArrowUp,
|
||
ChevronDown,
|
||
EyeOff,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useState, useTransition } from "react";
|
||
import { toast } from "sonner";
|
||
import { IconBadge } from "@/components/appearance/swatch";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { tr } from "@/i18n/tr";
|
||
import { cn } from "@/lib/utils";
|
||
import { ACCOUNT_TYPES, isLiabilityType } from "@/shared/accounts";
|
||
import { formatMoney } from "@/shared/format";
|
||
import { reorderAccountAction, setAccountArchivedAction } from "../actions";
|
||
import type { AccountTotals, AccountView } from "../service";
|
||
import { AccountFormDialog } from "./account-form-dialog";
|
||
|
||
const t = tr.accounts;
|
||
|
||
function BalanceText({ account }: { account: AccountView }) {
|
||
if (account.balanceMinor === null) {
|
||
return (
|
||
<span
|
||
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
|
||
title={t.hiddenHint}
|
||
>
|
||
<EyeOff className="size-3.5" />
|
||
{t.hidden}
|
||
</span>
|
||
);
|
||
}
|
||
const liability = isLiabilityType(account.type);
|
||
const value = account.balanceMinor;
|
||
return (
|
||
<span className="flex flex-col items-end">
|
||
{liability ? <span className="text-xs text-muted-foreground">{t.debt}</span> : null}
|
||
<span
|
||
className={cn(
|
||
"font-semibold tabular-nums",
|
||
(liability ? value < 0 : value < 0) && "text-expense",
|
||
)}
|
||
>
|
||
{formatMoney(liability ? Math.abs(value) : value)}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function AccountsView({
|
||
accounts,
|
||
totals,
|
||
members,
|
||
canEdit,
|
||
}: {
|
||
accounts: AccountView[];
|
||
totals: AccountTotals;
|
||
members: { id: string; name: string }[];
|
||
canEdit: boolean;
|
||
}) {
|
||
const [dialog, setDialog] = useState<{ account?: AccountView } | null>(null);
|
||
const [archiving, setArchiving] = useState<AccountView | null>(null);
|
||
const [pending, startTransition] = useTransition();
|
||
const active = accounts.filter((a) => !a.archived);
|
||
const archived = accounts.filter((a) => a.archived);
|
||
const memberName = (id: string | null) => members.find((m) => m.id === id)?.name;
|
||
const paymentAccounts = active
|
||
.filter((a) => ["bank", "cash", "savings"].includes(a.type))
|
||
.map((a) => ({ id: a.id, name: a.name }));
|
||
|
||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||
startTransition(async () => {
|
||
const r = await promise;
|
||
if (r.ok) {
|
||
if (success) toast.success(success);
|
||
} else toast.error(r.error ?? tr.common.genericError);
|
||
});
|
||
}
|
||
|
||
function Row({ a, index }: { a: AccountView; index: number }) {
|
||
const owner = memberName(a.ownerUserId);
|
||
return (
|
||
<li className={cn("flex items-center gap-3 p-3", a.archived && "opacity-60")}>
|
||
<IconBadge icon={a.icon} color={a.color} />
|
||
<div className="min-w-0 flex-1">
|
||
{/* Gizli hesabın ekstresi yalnızca sahibine açılır. */}
|
||
{a.balanceMinor === null ? (
|
||
<span className="block truncate font-medium">{a.name}</span>
|
||
) : (
|
||
<Link href={`/hesaplar/${a.id}`} className="block truncate font-medium hover:underline">
|
||
{a.name}
|
||
</Link>
|
||
)}
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||
{a.type === "meal_card" && a.mealCardProvider ? (
|
||
<span>{t.providers[a.mealCardProvider]}</span>
|
||
) : null}
|
||
{a.institution ? <span>{a.institution}</span> : null}
|
||
{owner ? <span>· {owner}</span> : null}
|
||
{a.isPrivate ? <Badge variant="outline">{t.privateBadge}</Badge> : null}
|
||
{a.overdraft ? (
|
||
<Badge variant="secondary">
|
||
{t.overdraftBadge(formatMoney(a.overdraft.limitMinor))}
|
||
</Badge>
|
||
) : null}
|
||
{a.creditCard ? (
|
||
<>
|
||
<Badge variant="secondary">
|
||
{t.limitLabel(formatMoney(a.creditCard.limitMinor))}
|
||
</Badge>
|
||
<span>{t.statementLabel(a.creditCard.statementDay)}</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<BalanceText account={a} />
|
||
{canEdit ? (
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={`${a.name} — ${tr.common.actions}`}
|
||
disabled={pending}
|
||
>
|
||
<MoreHorizontal />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
{a.archived ? (
|
||
<DropdownMenuItem
|
||
onSelect={() =>
|
||
run(setAccountArchivedAction(a.id, false), t.unarchivedToast(a.name))
|
||
}
|
||
>
|
||
<ArchiveRestore />
|
||
{t.unarchive}
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<>
|
||
<DropdownMenuItem onSelect={() => setDialog({ account: a })}>
|
||
<Pencil />
|
||
{tr.common.edit}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
disabled={index === 0}
|
||
onSelect={() => run(reorderAccountAction(a.id, "up"))}
|
||
>
|
||
<ArrowUp />
|
||
{tr.categories.moveUp}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
disabled={index === active.length - 1}
|
||
onSelect={() => run(reorderAccountAction(a.id, "down"))}
|
||
>
|
||
<ArrowDown />
|
||
{tr.categories.moveDown}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(a)}>
|
||
<Archive />
|
||
{t.archive}
|
||
</DropdownMenuItem>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
const summary = [
|
||
{ label: t.summary.assets, value: totals.assetsMinor, tone: "text-income" },
|
||
{
|
||
label: t.summary.cardDebt,
|
||
value: Math.abs(totals.cardDebtMinor),
|
||
tone: totals.cardDebtMinor ? "text-expense" : "",
|
||
},
|
||
{
|
||
label: t.summary.net,
|
||
value: totals.netMinor,
|
||
tone: totals.netMinor < 0 ? "text-expense" : "",
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{summary.map((s) => (
|
||
<Card key={s.label} size="sm">
|
||
<CardHeader>
|
||
<CardDescription>{s.label}</CardDescription>
|
||
<CardTitle className={cn("text-base tabular-nums sm:text-lg", s.tone)}>
|
||
{formatMoney(s.value)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
{totals.includesHidden || totals.excludesHidden ? (
|
||
<p className="-mt-2 text-xs text-muted-foreground">
|
||
{totals.excludesHidden ? t.summary.excludesHidden : t.summary.includesHidden}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-3">
|
||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.ownerOnly}</p> : null}
|
||
{canEdit ? (
|
||
<Button className="ml-auto" onClick={() => setDialog({})}>
|
||
<Plus />
|
||
{t.newAccount}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
{active.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||
<p>{t.empty}</p>
|
||
{canEdit ? <p className="mt-1 text-sm">{t.emptyOwnerHint}</p> : null}
|
||
</div>
|
||
) : (
|
||
ACCOUNT_TYPES.map((type) => {
|
||
const group = active.filter((a) => a.type === type);
|
||
if (!group.length) return null;
|
||
return (
|
||
<section
|
||
key={type}
|
||
aria-labelledby={`acc-group-${type}`}
|
||
className="flex flex-col gap-2"
|
||
>
|
||
<h2 id={`acc-group-${type}`} className="text-sm font-medium text-muted-foreground">
|
||
{t.typeGroups[type]}
|
||
</h2>
|
||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||
{group.map((a) => (
|
||
<Row key={a.id} a={a} index={active.indexOf(a)} />
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
})
|
||
)}
|
||
|
||
{archived.length ? (
|
||
<Collapsible>
|
||
<CollapsibleTrigger asChild>
|
||
<Button variant="ghost" className="-ml-2 w-fit">
|
||
<ChevronDown />
|
||
{t.archivedSection(archived.length)}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<ul className="mt-2 divide-y rounded-xl border bg-card">
|
||
{archived.map((a) => (
|
||
<Row key={a.id} a={a} index={-1} />
|
||
))}
|
||
</ul>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
) : null}
|
||
|
||
<AccountFormDialog
|
||
open={Boolean(dialog)}
|
||
account={dialog?.account}
|
||
members={members}
|
||
paymentAccounts={paymentAccounts}
|
||
onOpenChange={(o) => !o && setDialog(null)}
|
||
/>
|
||
|
||
<AlertDialog open={Boolean(archiving)} onOpenChange={(o) => !o && setArchiving(null)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{archiving ? t.archiveDescription(archiving.name) : null}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => {
|
||
const a = archiving!;
|
||
setArchiving(null);
|
||
run(setAccountArchivedAction(a.id, true), t.archivedToast(a.name));
|
||
}}
|
||
>
|
||
{t.archive}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|