fa70168622
- /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>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|