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,315 @@
"use client";
import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate } from "@/shared/format";
import { foldTr } from "@/shared/search";
import { formatStock, stepLabel, type ExpiringLot } from "@/shared/stock";
import { quickConsumeAction } from "../actions";
import type { ProductTypeView, StockOption, StockView as Data } from "../service";
import { ProductTypeFormDialog } from "./stock-dialogs";
const t = tr.stock;
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
/** Mevcut/hedef çubuğu; eşik çizgiyle işaretli. */
export function StockBar({ item }: { item: ProductTypeView }) {
const pct = Math.min(100, item.level.pct ?? 0);
return (
<div
className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label={t.list.of(
formatStock(Math.max(0, item.current), item.unit),
formatStock(item.targetQuantity, item.unit),
)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div
className={cn("h-full rounded-full", item.level.low ? "bg-expense" : "bg-budget")}
style={{ width: `${pct}%` }}
/>
{item.thresholdPct > 0 && item.thresholdPct < 100 ? (
<span
aria-hidden
className="absolute top-0 h-full w-0.5 bg-foreground/50"
style={{ left: `${item.thresholdPct}%` }}
/>
) : null}
</div>
);
}
export function LevelBadge({ item }: { item: ProductTypeView }) {
if (item.current <= 0 && item.targetQuantity > 0)
return <Badge variant="destructive">{t.list.outBadge}</Badge>;
if (item.level.low)
return (
<Badge variant="outline" className="gap-1 border-expense/50 text-expense">
<AlertTriangle className="size-3" />
{t.list.lowBadge}
</Badge>
);
return null;
}
export function ExpiryBadge({ lot }: { lot: Pick<ExpiringLot, "expiryDate" | "status"> }) {
return (
<Badge
variant="outline"
className={cn(
"gap-1",
lot.status === "expired"
? "border-expense/50 text-expense"
: "border-amber-500/50 text-amber-700 dark:text-amber-400",
)}
>
<Clock className="size-3" />
{t.expiring.on(ymdLabel(lot.expiryDate))}
</Badge>
);
}
/** "1 azalt" düğmesi. */
export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
const [pending, start] = useTransition();
return (
<Button
size="sm"
variant="outline"
className="shrink-0 tabular-nums"
disabled={pending || item.current <= 0}
aria-label={t.list.quickConsume(item.name)}
onClick={() =>
start(async () => {
const r = await quickConsumeAction(item.id);
if (r.ok)
toast.success(
t.list.quickDone(
item.name,
formatStock((r.data as { remaining: number }).remaining, item.unit),
),
);
else toast.error(r.error);
})
}
>
{stepLabel(item.step, item.unit)}
</Button>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-lg font-semibold tabular-nums", value > 0 && tone)}>{value}</p>
</div>
);
}
/** /stok: özet, öneriler, SKT uyarıları ve kategoriye göre ürün listesi. */
export function StockView({ data, options }: { data: Data; options: StockOption[] }) {
const router = useRouter();
const [creating, setCreating] = useState(false);
const [query, setQuery] = useState("");
const [onlyLow, setOnlyLow] = useState(false);
const expired = data.expiring.filter((e) => e.lot.status === "expired").length;
const groups = useMemo(() => {
const q = foldTr(query.trim());
const match = (i: ProductTypeView) =>
(!onlyLow || i.level.low) &&
(!q || foldTr(i.name).includes(q) || foldTr(i.location).includes(q));
return data.categories
.map((c) => ({
category: c,
items: data.items.filter((i) => i.categoryId === c.id && match(i)),
}))
.filter((g) => g.items.length > 0);
}, [data, query, onlyLow]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/kategoriler">
<FolderTree />
{t.categoriesLink}
</Link>
</Button>
{data.canWrite ? (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
{t.newProduct}
</Button>
) : null}
</div>
<Card size="sm">
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.summary.products} value={data.items.length} />
<Stat label={t.summary.low} value={data.suggestions.length} tone="text-expense" />
<Stat
label={t.summary.expiring}
value={data.expiring.length - expired}
tone="text-amber-700 dark:text-amber-400"
/>
<Stat label={t.summary.expired} value={expired} tone="text-expense" />
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
<CardDescription>{t.suggestions.description}</CardDescription>
</CardHeader>
<CardContent>
{data.suggestions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
) : (
<ul className="divide-y text-sm">
{data.suggestions.map((i) => (
<li key={i.id} className="flex items-center justify-between gap-3 py-2">
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
<span className="block truncate font-medium">{i.name}</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{t.list.of(
formatStock(Math.max(0, i.current), i.unit),
formatStock(i.targetQuantity, i.unit),
)}
</span>
</Link>
<span className="shrink-0 text-right font-medium tabular-nums">
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{data.expiring.length ? (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.expiring.title}</CardTitle>
<CardDescription>{t.expiring.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{data.expiring.map(({ item, lot }) => (
<li
key={`${item.id}-${lot.index}`}
className="flex items-center justify-between gap-3 py-2"
>
<Link href={`/stok/${item.id}`} className="min-w-0 hover:underline">
<span className="block truncate font-medium">{item.name}</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatStock(lot.remaining, item.unit)}
</span>
</Link>
<ExpiryBadge lot={lot} />
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
{data.items.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.list.empty}
</p>
) : (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-48 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.list.search}
aria-label={t.list.search}
className="pl-8"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onlyLow} onCheckedChange={(x) => setOnlyLow(x === true)} />
{t.list.onlyLow}
</label>
</div>
{groups.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.list.noMatch}</p>
) : null}
{groups.map(({ category, items }) => (
<section key={category.id} className="flex flex-col gap-2">
<h2 className="text-sm font-medium text-muted-foreground">{category.path}</h2>
<ul className="flex flex-col gap-2">
{items.map((item) => (
<StockRow key={item.id} item={item} canWrite={data.canWrite} />
))}
</ul>
</section>
))}
</>
)}
{creating ? (
<ProductTypeFormDialog
item={null}
categories={options}
onClose={() => setCreating(false)}
onCreated={(id) => router.push(`/stok/${id}`)}
/>
) : null}
</div>
);
}
function StockRow({ item, canWrite }: { item: ProductTypeView; canWrite: boolean }) {
const lot = item.expiring[0];
return (
<li className="flex items-center gap-2 rounded-xl border bg-card p-3">
<Link href={`/stok/${item.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<p className="flex min-w-0 flex-wrap items-center gap-1.5 font-medium">
<span className="truncate">{item.name}</span>
<LevelBadge item={item} />
{lot ? <ExpiryBadge lot={lot} /> : null}
</p>
<span className="shrink-0 text-sm tabular-nums">
<span className="font-semibold">
{formatStock(Math.max(0, item.current), item.unit)}
</span>
<span className="text-muted-foreground">
{" "}
/ {formatStock(item.targetQuantity, item.unit)}
</span>
</span>
</div>
<StockBar item={item} />
{item.location ? (
<p className="truncate text-xs text-muted-foreground">{item.location}</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
{canWrite ? <QuickConsumeButton item={item} /> : null}
</li>
);
}