Files
butcem/src/features/stock/components/product-detail-view.tsx
T

381 lines
14 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,
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>
);
}