Files
butcem/src/features/accounts/components/accounts-view.tsx
T

324 lines
11 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 {
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>
);
}