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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user