Files
butcem/src/features/stock/components/stock-view.tsx
T
suphicakir fa70168622 feat(ui): alışveriş listeleri, akıllı öneri paneli ve alışveriş modu
- /alisveris, /alisveris/:id ve /alisveris/:id/mod sayfaları; odak ve 30 sn yenileme
- stok önerileri paneli, kalem ekle/düzenle/taşı, mağazaya gruplu kontrol listesi
- dashboard Alışveriş kartı, kenar menü, ürün tipi formunda hacimli işareti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00

410 lines
15 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 {
AlertTriangle,
ChartColumn,
ChevronRight,
ClipboardCheck,
Clock,
FolderTree,
Plus,
Search,
Tags,
} 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>
);
}
/** Önerilen miktarın gerekçesi: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
export function suggestionReasons(item: ProductTypeView): string[] {
const s = item.suggestion;
if (s.quantity === 0) return [];
const out: string[] = [];
if (s.waste) out.push(t.suggestions.wasteReduced(s.waste.purchases, s.waste.pct));
if (s.roundedByUsage) out.push(t.suggestions.roundedByUsage(formatStock(s.roundTo, item.unit)));
if (s.coverDays !== null) out.push(t.suggestions.covers(s.coverDays));
return out;
}
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/urunler">
<Tags />
{tr.products.catalogLink}
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/analiz">
<ChartColumn />
{t.analysisLink}
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/sayim">
<ClipboardCheck />
{t.countLink}
</Link>
</Button>
<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 className="flex flex-col gap-3">
{data.suggestions.length ? (
<Link href="/alisveris" className="text-sm text-primary hover:underline">
{t.suggestions.toShopping}
</Link>
) : null}
{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) => {
const reasons = suggestionReasons(i);
return (
<li key={i.id} className="flex flex-col gap-0.5 py-2">
<div className="flex items-center justify-between gap-3">
<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>
</div>
{reasons.length ? (
<p
className={cn(
"text-xs",
i.suggestion.waste
? "text-amber-700 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{reasons.join(" ")}
</p>
) : null}
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
{data.countDue.length ? (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.countDue.title}</CardTitle>
<CardDescription>{t.countDue.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{data.countDue.map((g) => (
<li key={g.categoryId} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<span className="block truncate font-medium">{g.path}</span>
<span className="block text-xs text-muted-foreground">
{t.countDue.products(g.products)} ·{" "}
<span className="text-amber-700 dark:text-amber-400">
{t.countDue.overdue(g.overdueDays)}
</span>
</span>
</div>
{data.canWrite ? (
<Button size="sm" variant="outline" asChild>
<Link href={`/stok/sayim?kategori=${g.categoryId}`}>
<ClipboardCheck />
{t.countDue.start}
</Link>
</Button>
) : null}
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
{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>
);
}