feat(ui): ev stoğu sayfaları ve dashboard azalan stoklar kartı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 08:10:10 +03:00
parent d9de29c18d
commit 868bb23fd9
11 changed files with 1704 additions and 2 deletions
@@ -0,0 +1,326 @@
"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 } 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, 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} />
{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>
);
}