a818c110cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowLeft,
|
||
EllipsisVertical,
|
||
Gift,
|
||
HandCoins,
|
||
Lock,
|
||
Pencil,
|
||
ShoppingBag,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { PageHeader } from "@/components/page-header";
|
||
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, CardContent } from "@/components/ui/card";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { tr } from "@/i18n/tr";
|
||
import { ASSETS, formatAssetQuantity } from "@/shared/assets";
|
||
import { formatDate, formatMoney } from "@/shared/format";
|
||
import { isInflow, type MovementKind } from "@/shared/portfolio";
|
||
import { deleteAssetAction, deleteMovementAction } from "../actions";
|
||
import type { AssetDetail, MovementRow } from "../service";
|
||
import { AssetFormDialog, MovementDialog } from "./asset-dialogs";
|
||
import { ProfitLoss, Stat } from "./assets-view";
|
||
|
||
const t = tr.assets;
|
||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||
|
||
type Pending =
|
||
| { kind: "edit" }
|
||
| { kind: "movement"; movement: MovementKind }
|
||
| { kind: "deleteMovement"; row: MovementRow }
|
||
| { kind: "deleteAsset" }
|
||
| null;
|
||
|
||
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||
export function AssetDetailView({ data }: { data: AssetDetail }) {
|
||
const router = useRouter();
|
||
const [pending, setPending] = useState<Pending>(null);
|
||
const close = () => setPending(null);
|
||
const s = data.summary;
|
||
const unit = ASSETS[data.assetKey].unit;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||
<Link href="/birikim">
|
||
<ArrowLeft />
|
||
{t.back}
|
||
</Link>
|
||
</Button>
|
||
<PageHeader
|
||
title={data.name}
|
||
description={ASSETS[data.assetKey].label}
|
||
actions={
|
||
data.canWrite ? (
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||
<EllipsisVertical />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onSelect={() => setPending({ kind: "edit" })}>
|
||
<Pencil />
|
||
{tr.common.edit}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
disabled={data.movementCount > 0}
|
||
onSelect={() => setPending({ kind: "deleteAsset" })}
|
||
>
|
||
<Trash2 />
|
||
{t.delete}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null
|
||
}
|
||
/>
|
||
{data.isPrivate ? (
|
||
<Badge variant="outline" className="-mt-2 w-fit gap-1">
|
||
<Lock className="size-3" />
|
||
{t.privateBadge}
|
||
</Badge>
|
||
) : null}
|
||
|
||
<Card size="sm">
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Stat label={t.stats.quantity}>{formatAssetQuantity(data.assetKey, s.quantity)}</Stat>
|
||
<Stat label={t.stats.avgCost}>
|
||
{s.avgCostMinor !== null
|
||
? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}`
|
||
: "—"}
|
||
</Stat>
|
||
<Stat label={t.stats.cost}>{formatMoney(s.costMinor)}</Stat>
|
||
<Stat label={t.stats.currentValue}>
|
||
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||
</Stat>
|
||
<Stat label={t.stats.unrealized}>
|
||
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} />
|
||
</Stat>
|
||
<Stat label={t.stats.realized}>
|
||
<ProfitLoss minor={s.realizedMinor} />
|
||
</Stat>
|
||
<Stat label={t.stats.invested}>{formatMoney(s.investedMinor)}</Stat>
|
||
<Stat label={t.stats.proceeds}>{formatMoney(s.proceedsMinor)}</Stat>
|
||
</div>
|
||
{data.price ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "}
|
||
<Link href="/fiyatlar" className="underline-offset-2 hover:underline">
|
||
{t.prices}
|
||
</Link>
|
||
</p>
|
||
) : null}
|
||
{data.storage ? (
|
||
<p className="text-sm">
|
||
<span className="text-muted-foreground">{t.movement.storage}:</span> {data.storage}
|
||
</p>
|
||
) : null}
|
||
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||
{data.canWrite ? (
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPending({ kind: "movement", movement: "gift_in" })}
|
||
>
|
||
<Gift />
|
||
{t.kinds.gift_in}
|
||
</Button>
|
||
{s.quantity > 0 ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPending({ kind: "movement", movement: "sell" })}
|
||
>
|
||
<HandCoins />
|
||
{t.kinds.sell}
|
||
</Button>
|
||
) : null}
|
||
<Button size="sm" onClick={() => setPending({ kind: "movement", movement: "buy" })}>
|
||
<ShoppingBag />
|
||
{t.kinds.buy}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||
<h2 id="movements-title" className="text-base font-medium">
|
||
{t.history}
|
||
</h2>
|
||
{data.movements.length === 0 ? (
|
||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||
{t.noMovements}
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded-xl border bg-card">
|
||
{data.movements.map((row) => (
|
||
<MovementItem
|
||
key={row.id}
|
||
row={row}
|
||
data={data}
|
||
canWrite={data.canWrite}
|
||
onDelete={() => setPending({ kind: "deleteMovement", row })}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
{pending?.kind === "edit" ? <AssetFormDialog item={data} onClose={close} /> : null}
|
||
{pending?.kind === "movement" ? (
|
||
<MovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||
) : null}
|
||
<AlertDialog
|
||
open={pending?.kind === "deleteMovement" || pending?.kind === "deleteAsset"}
|
||
onOpenChange={(o) => !o && close()}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>
|
||
{pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle}
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={async () => {
|
||
if (pending?.kind === "deleteAsset") {
|
||
const r = await deleteAssetAction(data.id);
|
||
close();
|
||
if (r.ok) {
|
||
toast.success(t.toast.deleted);
|
||
router.push("/birikim");
|
||
} else toast.error(r.error);
|
||
} else if (pending?.kind === "deleteMovement") {
|
||
const r = await deleteMovementAction(pending.row.id);
|
||
close();
|
||
if (r.ok) toast.success(t.toast.movementDeleted);
|
||
else toast.error(r.error);
|
||
}
|
||
}}
|
||
>
|
||
{pending?.kind === "deleteAsset" ? t.delete : t.deleteMovement}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MovementItem({
|
||
row,
|
||
data,
|
||
canWrite,
|
||
onDelete,
|
||
}: {
|
||
row: MovementRow;
|
||
data: AssetDetail;
|
||
canWrite: boolean;
|
||
onDelete: () => void;
|
||
}) {
|
||
const inflow = isInflow(row.kind);
|
||
const qty = formatAssetQuantity(data.assetKey, row.quantity);
|
||
const unit = ASSETS[data.assetKey].unit;
|
||
return (
|
||
<li className="flex items-start gap-3 p-3">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||
<span>{t.kinds[row.kind]}</span>
|
||
<span className={inflow ? "text-income" : "text-expense"}>
|
||
{inflow ? "+" : "−"}
|
||
{qty}
|
||
</span>
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{d(row.date)}
|
||
{row.unitPriceMinor !== null
|
||
? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}`
|
||
: ""}
|
||
{row.account ? ` · ${row.account}` : ""}
|
||
{row.storage ? ` · ${row.storage}` : ""}
|
||
</p>
|
||
{row.note ? <p className="text-xs text-muted-foreground">{row.note}</p> : null}
|
||
<p className="text-xs text-muted-foreground">
|
||
{t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)}
|
||
</p>
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end text-sm">
|
||
{row.kind !== "gift_out" ? (
|
||
<span className="tabular-nums">{formatMoney(row.amountMinor)}</span>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground tabular-nums">
|
||
{t.stats.cost} {formatMoney(row.costOutMinor)}
|
||
</span>
|
||
)}
|
||
{row.kind === "sell" ? (
|
||
<span className="text-xs">
|
||
{t.table.result}: <ProfitLoss minor={row.realizedMinor} />
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{canWrite && !row.fromInKind ? (
|
||
<Button
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
aria-label={t.deleteMovement}
|
||
onClick={onDelete}
|
||
className="shrink-0"
|
||
>
|
||
<Trash2 />
|
||
</Button>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|