07ba531d31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Archive,
|
||
ArchiveRestore,
|
||
ArrowLeft,
|
||
ClipboardCheck,
|
||
EllipsisVertical,
|
||
Minus,
|
||
Pencil,
|
||
ShoppingBag,
|
||
Trash,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
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, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { tr } from "@/i18n/tr";
|
||
import { cn } from "@/lib/utils";
|
||
import { formatStock, roundForDisplay } from "@/shared/stock";
|
||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||
import {
|
||
ProductTypeFormDialog,
|
||
StockCountDialog,
|
||
StockMovementDialog,
|
||
type MovementKindInput,
|
||
} from "./stock-dialogs";
|
||
import {
|
||
ExpiryBadge,
|
||
LevelBadge,
|
||
QuickConsumeButton,
|
||
StockBar,
|
||
suggestionReasons,
|
||
ymdLabel,
|
||
} from "./stock-view";
|
||
|
||
const t = tr.stock;
|
||
|
||
type Pending =
|
||
| { kind: "edit" }
|
||
| { kind: "movement"; movement: MovementKindInput }
|
||
| { kind: "count" }
|
||
| { kind: "archive" }
|
||
| { kind: "deleteMovement"; row: StockMovementRow }
|
||
| null;
|
||
|
||
function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<p className="text-xs text-muted-foreground">{label}</p>
|
||
<div className="font-semibold tabular-nums">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */
|
||
export function ProductDetailView({
|
||
data,
|
||
categories,
|
||
}: {
|
||
data: ProductTypeDetail;
|
||
categories: StockOption[];
|
||
}) {
|
||
const [pending, setPending] = useState<Pending>(null);
|
||
const close = () => setPending(null);
|
||
const q = (x: number) => formatStock(x, data.unit);
|
||
const active = data.canWrite && !data.archived;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||
<Link href="/stok">
|
||
<ArrowLeft />
|
||
{t.detail.back}
|
||
</Link>
|
||
</Button>
|
||
<PageHeader
|
||
title={data.name}
|
||
description={data.categoryPath}
|
||
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
|
||
disabled={data.archived}
|
||
onSelect={() => setPending({ kind: "edit" })}
|
||
>
|
||
<Pencil />
|
||
{tr.common.edit}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
{data.archived ? (
|
||
<DropdownMenuItem
|
||
onSelect={async () => {
|
||
const r = await setProductTypeArchivedAction(data.id, false);
|
||
if (r.ok) toast.success(t.toast.unarchived);
|
||
else toast.error(r.error);
|
||
}}
|
||
>
|
||
<ArchiveRestore />
|
||
{t.detail.unarchive}
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onSelect={() => setPending({ kind: "archive" })}
|
||
>
|
||
<Archive />
|
||
{t.detail.archive}
|
||
</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null
|
||
}
|
||
/>
|
||
<div className="-mt-2 flex flex-wrap gap-1.5">
|
||
{data.archived ? <Badge variant="secondary">{t.detail.archived}</Badge> : null}
|
||
<LevelBadge item={data} />
|
||
</div>
|
||
|
||
<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.detail.current}>
|
||
<span className={cn(data.level.low && "text-expense")}>
|
||
{q(Math.max(0, data.current))}
|
||
</span>
|
||
</Stat>
|
||
<Stat label={t.detail.target}>{q(data.targetQuantity)}</Stat>
|
||
<Stat label={t.detail.threshold}>
|
||
{q(data.level.thresholdQuantity)}{" "}
|
||
<span className="text-xs font-normal text-muted-foreground">
|
||
(%{data.thresholdPct})
|
||
</span>
|
||
</Stat>
|
||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||
</div>
|
||
<StockBar item={data} />
|
||
{suggestionReasons(data).length ? (
|
||
<p
|
||
className={cn(
|
||
"text-xs",
|
||
data.suggestion.waste
|
||
? "text-amber-700 dark:text-amber-400"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{suggestionReasons(data).join(" ")}
|
||
</p>
|
||
) : null}
|
||
<div className="grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||
<Stat label={t.detail.usage}>
|
||
{data.suggestion.usage ? (
|
||
t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7)))
|
||
) : (
|
||
<span className="font-normal text-muted-foreground">{t.detail.noUsage}</span>
|
||
)}
|
||
</Stat>
|
||
<Stat label={t.detail.countSchedule}>
|
||
{data.count ? (
|
||
<span className="flex flex-wrap items-center gap-x-2">
|
||
<span>{t.detail.countEvery(data.count.everyDays)}</span>
|
||
<span
|
||
className={cn(
|
||
"text-xs font-normal",
|
||
data.count.due
|
||
? "text-amber-700 dark:text-amber-400"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{data.count.due
|
||
? tr.stock.count.dueBadge(data.count.overdueDays)
|
||
: t.detail.nextCount(ymdLabel(data.count.nextDate))}
|
||
{data.count.lastDate
|
||
? ` · ${tr.stock.count.lastCount(ymdLabel(data.count.lastDate))}`
|
||
: ""}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="font-normal text-muted-foreground">
|
||
{t.detail.noCountSchedule}
|
||
</span>
|
||
)}
|
||
</Stat>
|
||
</div>
|
||
{data.location ? (
|
||
<p className="text-sm">
|
||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
||
</p>
|
||
) : null}
|
||
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||
{active ? (
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
<QuickConsumeButton item={data} />
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPending({ kind: "movement", movement: "consume" })}
|
||
>
|
||
<Minus />
|
||
{t.actions.consume}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPending({ kind: "movement", movement: "waste" })}
|
||
>
|
||
<Trash />
|
||
{t.actions.waste}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => setPending({ kind: "count" })}>
|
||
<ClipboardCheck />
|
||
{t.actions.count}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setPending({ kind: "movement", movement: "purchase" })}
|
||
>
|
||
<ShoppingBag />
|
||
{t.actions.purchase}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card size="sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{t.detail.lots}</CardTitle>
|
||
<p className="text-xs text-muted-foreground">{t.detail.lotsHint}</p>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{data.lots.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{t.detail.noLots}</p>
|
||
) : (
|
||
<ul className="divide-y text-sm">
|
||
{data.lots.map((lot, i) => (
|
||
<li key={i} className="flex items-center justify-between gap-3 py-2">
|
||
<span className="min-w-0">
|
||
<span className="font-medium tabular-nums">{q(lot.remaining)}</span>
|
||
<span className="text-xs text-muted-foreground"> · {ymdLabel(lot.date)}</span>
|
||
</span>
|
||
{lot.expiryDate && lot.status && lot.status !== "ok" ? (
|
||
<ExpiryBadge lot={{ expiryDate: lot.expiryDate, status: lot.status }} />
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">
|
||
{lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry}
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
||
<h2 id="stock-movements-title" className="text-base font-medium">
|
||
{t.detail.movements}
|
||
</h2>
|
||
{data.movements.length === 0 ? (
|
||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||
{t.detail.noMovements}
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded-xl border bg-card">
|
||
{data.movements.map((row) => (
|
||
<li key={row.id} 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>{row.initial ? t.initial : t.kinds[row.kind]}</span>
|
||
<span
|
||
className={cn("tabular-nums", row.delta > 0 ? "text-income" : "text-expense")}
|
||
>
|
||
{row.delta > 0 ? "+" : "−"}
|
||
{q(Math.abs(row.delta))}
|
||
</span>
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{ymdLabel(row.date)}
|
||
{row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""}
|
||
{row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""}
|
||
{row.note ? ` · ${row.note}` : ""}
|
||
</p>
|
||
</div>
|
||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||
{t.detail.after(q(row.quantityAfter))}
|
||
</span>
|
||
{data.canWrite ? (
|
||
<Button
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
aria-label={t.detail.deleteMovement}
|
||
onClick={() => setPending({ kind: "deleteMovement", row })}
|
||
className="shrink-0"
|
||
>
|
||
<Trash2 />
|
||
</Button>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
{pending?.kind === "edit" ? (
|
||
<ProductTypeFormDialog item={data} categories={categories} onClose={close} />
|
||
) : null}
|
||
{pending?.kind === "movement" ? (
|
||
<StockMovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||
) : null}
|
||
{pending?.kind === "count" ? <StockCountDialog item={data} onClose={close} /> : null}
|
||
<AlertDialog
|
||
open={pending?.kind === "archive" || pending?.kind === "deleteMovement"}
|
||
onOpenChange={(o) => !o && close()}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>
|
||
{pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle}
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{pending?.kind === "archive"
|
||
? t.detail.archiveDescription(data.name)
|
||
: t.detail.deleteMovementDescription}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={async () => {
|
||
const r =
|
||
pending?.kind === "archive"
|
||
? await setProductTypeArchivedAction(data.id, true)
|
||
: pending?.kind === "deleteMovement"
|
||
? await deleteStockMovementAction(pending.row.id)
|
||
: null;
|
||
const wasArchive = pending?.kind === "archive";
|
||
close();
|
||
if (!r) return;
|
||
if (r.ok) toast.success(wasArchive ? t.toast.archived : t.toast.deleted);
|
||
else toast.error(r.error);
|
||
}}
|
||
>
|
||
{pending?.kind === "archive" ? t.detail.archive : t.detail.deleteMovement}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|