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:
@@ -6,6 +6,28 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
|
||||
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
|
||||
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç (en fazla 5 seviye). Varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanelerde ve migration ile mevcut hanelerde kurulur. Her kategori bir gider kategorisine eşlenebilir (üstten devralınır; alışveriş tamamlama için), sayım sıklığı alanı (üstten devralınır). `/stok/kategoriler`: ekle, düzenle, taşı, arşivle (içinde aktif ürün varken arşivlenemez).
|
||||
- Ürün tipleri (`productTypes`): kategori, temel birim (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik (%20 varsayılan), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki mevcut miktar. Arşivleme.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı son kullanma tarihi), tüketim, fire/zarar (neden ile), sayım düzeltmesi (gerçek miktar girilir, fark otomatik). Mevcut miktar hareketlerden türetilir; stoktan fazla tüketim/fire reddedilir; hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO: çıkışlar en eski alımdan düşülür; evde kalan partilerin SKT'si 7 gün içinde dolan veya geçmiş olanlar uyarılır.
|
||||
- `/stok`: özet (ürün, azalan, SKT yaklaşan/geçmiş), "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT listesi, kategoriye göre ürünler (mevcut/hedef çubuğu ve eşik çizgisi), arama, "yalnızca azalanlar", tek dokunuşla "1 azalt". `/stok/:id`: durum, partiler, hareketler, alım/tüketim/fire/sayım.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık tüketim, alım, fire ve sayım hareketleri.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, gider eşlemesi, izolasyon, hareket kuralları, sayım, arşiv ve kategori kuralları (entegrasyon).
|
||||
|
||||
## [0.10.0] - 2026-11-06
|
||||
|
||||
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -105,6 +106,9 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.detail.movements };
|
||||
|
||||
/** /stok/:id — ürün tipi: mevcut durum, partiler ve hareketler (başka hanenin kaydı → 404). */
|
||||
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, options] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return <ProductDetailView data={data} categories={options.categories} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StockCategoriesManager } from "@/features/stock/components/stock-categories-manager";
|
||||
import { getStockOptions, listStockCategories } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.categories.title };
|
||||
|
||||
/** /stok/kategoriler — stok kategori ağacı, gider eşlemesi ve sayım sıklığı. */
|
||||
export default async function StockCategoriesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, options] = await Promise.all([
|
||||
listStockCategories(user.id, household.id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stock.categories.title} description={tr.stock.categories.description} />
|
||||
<StockCategoriesManager
|
||||
categories={data.categories}
|
||||
expenseCategories={options.expenseCategories}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StockView } from "@/features/stock/components/stock-view";
|
||||
import { getStock, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.title };
|
||||
|
||||
/** /stok — ev stoğu: özet, alınması önerilenler, SKT uyarıları, kategoriye göre ürünler. */
|
||||
export default async function StockPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, options] = await Promise.all([
|
||||
getStock(user.id, household.id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stock.title} description={tr.stock.description} />
|
||||
{!data.canWrite ? <p className="text-sm text-muted-foreground">{tr.stock.readOnly}</p> : null}
|
||||
<StockView data={data} options={options.categories} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 10.2;
|
||||
export const CURRENT_PHASE = 11.1;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -40,7 +40,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import { getStock } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
const LIMIT = 5;
|
||||
|
||||
/** Dashboard: eşiğin altındaki ürünler ve SKT'si yaklaşan partiler (yoksa görünmez). */
|
||||
export async function LowStockCard({
|
||||
userId,
|
||||
householdId,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
}) {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length)) return null;
|
||||
const low = data.suggestions.slice(0, LIMIT);
|
||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.dashboard.description(data.suggestions.length, data.expiring.length)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ul className="divide-y text-sm">
|
||||
{low.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 truncate hover:underline">
|
||||
{i.name}
|
||||
</Link>
|
||||
<span className="shrink-0 text-xs tabular-nums">
|
||||
<span className="text-expense">{formatStock(Math.max(0, i.current), i.unit)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{expiring.map(({ item, lot }) => (
|
||||
<li
|
||||
key={`${item.id}-${lot.index}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link href={`/stok/${item.id}`} className="min-w-0 truncate hover:underline">
|
||||
{item.name}
|
||||
</Link>
|
||||
<span
|
||||
className={
|
||||
lot.status === "expired"
|
||||
? "flex shrink-0 items-center gap-1 text-xs text-expense"
|
||||
: "flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{t.expiring.on(
|
||||
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||
new Date(`${lot.expiryDate}T12:00:00+03:00`),
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.suggestions.length > low.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.dashboard.more(data.suggestions.length - low.length)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" className="w-fit" asChild>
|
||||
<Link href="/stok">{t.dashboard.open}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { saveStockCategoryAction, setStockCategoryArchivedAction } from "../actions";
|
||||
import type { StockCategoryView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock.categories;
|
||||
const NONE = "__none__";
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
type Pending =
|
||||
| { kind: "create"; parentId: string | null }
|
||||
| { kind: "edit"; category: StockCategoryView }
|
||||
| { kind: "archive"; category: StockCategoryView }
|
||||
| null;
|
||||
|
||||
/** /stok/kategoriler: ağaç, gider eşlemesi, sayım sıklığı, arşiv. */
|
||||
export function StockCategoriesManager({
|
||||
categories,
|
||||
expenseCategories,
|
||||
canWrite,
|
||||
}: {
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
const visible = categories.filter((c) => showArchived || !c.archived);
|
||||
|
||||
async function unarchive(c: StockCategoryView) {
|
||||
const r = await setStockCategoryArchivedAction(c.id, false);
|
||||
if (r.ok) toast.success(tr.stock.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setPending({ kind: "create", parentId: null })}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{visible.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn("flex items-center gap-2 p-3", c.archived && "opacity-60")}
|
||||
style={{ paddingLeft: `${12 + c.depth * 20}px` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.isDefault ? <Badge variant="secondary">{t.defaultBadge}</Badge> : null}
|
||||
{c.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[
|
||||
c.expense
|
||||
? c.expense.inherited
|
||||
? t.inherited(c.expense.path)
|
||||
: c.expense.path
|
||||
: t.noExpense,
|
||||
c.effectiveCountDays ? t.everyDays(c.effectiveCountDays) : null,
|
||||
c.productCount ? t.productCount(c.productCount) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.common.actions}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{c.archived ? (
|
||||
<DropdownMenuItem onSelect={() => unarchive(c)}>
|
||||
<ArchiveRestore />
|
||||
{t.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "create", parentId: c.id })}
|
||||
>
|
||||
<FolderPlus />
|
||||
{t.addChild}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "edit", category: c })}
|
||||
>
|
||||
<Pencil />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setPending({ kind: "archive", category: c })}
|
||||
>
|
||||
<Archive />
|
||||
{t.archive}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pending?.kind === "create" || pending?.kind === "edit" ? (
|
||||
<StockCategoryDialog
|
||||
category={pending.kind === "edit" ? pending.category : null}
|
||||
parentId={
|
||||
pending.kind === "create" ? pending.parentId : (pending.category.parentId ?? null)
|
||||
}
|
||||
categories={categories}
|
||||
expenseCategories={expenseCategories}
|
||||
onClose={close}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog open={pending?.kind === "archive"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "archive") return;
|
||||
const r = await setStockCategoryArchivedAction(pending.category.id, true);
|
||||
close();
|
||||
if (r.ok) toast.success(tr.stock.toast.archived);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockCategoryDialog({
|
||||
category,
|
||||
parentId,
|
||||
categories,
|
||||
expenseCategories,
|
||||
onClose,
|
||||
}: {
|
||||
category: StockCategoryView | null;
|
||||
parentId: string | null;
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
name: category?.name ?? "",
|
||||
parentId: parentId ?? "",
|
||||
expenseCategoryId: category?.expenseCategoryId ?? "",
|
||||
countEveryDays: category?.countEveryDays ? String(category.countEveryDays) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
// Kendisi ve alt ağacı üst olarak seçilemez.
|
||||
const parents = useMemo(() => {
|
||||
if (!category) return categories.filter((c) => !c.archived);
|
||||
const blocked = new Set([category.id]);
|
||||
for (const c of categories) if (c.parentId && blocked.has(c.parentId)) blocked.add(c.id);
|
||||
return categories.filter((c) => !c.archived && !blocked.has(c.id));
|
||||
}, [categories, category]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveStockCategoryAction(category?.id ?? null, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(tr.stock.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={category ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="stock-category-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="stock-category-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="sc-name">{t.name}</FieldLabel>
|
||||
<Input
|
||||
id="sc-name"
|
||||
value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.parentId)}>
|
||||
<FieldLabel htmlFor="sc-parent">{t.parent}</FieldLabel>
|
||||
<Select
|
||||
value={v.parentId || NONE}
|
||||
onValueChange={(x) => set("parentId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-parent" className="w-full">
|
||||
<SelectValue>
|
||||
{categories.find((c) => c.id === v.parentId)?.path ?? t.noParent}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.noParent}</SelectItem>
|
||||
{parents.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.parentId ? <FieldError>{errors.parentId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.expenseCategoryId)}>
|
||||
<FieldLabel htmlFor="sc-expense">{t.expenseCategory}</FieldLabel>
|
||||
<Select
|
||||
value={v.expenseCategoryId || NONE}
|
||||
onValueChange={(x) => set("expenseCategoryId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-expense" className="w-full">
|
||||
<SelectValue>
|
||||
{expenseCategories.find((c) => c.id === v.expenseCategoryId)?.path ??
|
||||
t.expenseInherit}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.expenseInherit}</SelectItem>
|
||||
{expenseCategories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{t.expenseHint}</FieldDescription>
|
||||
{errors.expenseCategoryId ? <FieldError>{errors.expenseCategoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="sc-count">{t.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="sc-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="w-32 text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
<FieldDescription>{t.countEveryDaysHint}</FieldDescription>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
formatStock,
|
||||
quantityInput,
|
||||
STOCK_UNITS,
|
||||
WASTE_REASONS,
|
||||
type WasteReason,
|
||||
} from "@/shared/stock";
|
||||
import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions";
|
||||
import type { ProductTypeView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const CUSTOM = "__custom__";
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorsOf(r: { fieldErrors?: Partial<Record<string, string>>; error: string }): Errors {
|
||||
return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error };
|
||||
}
|
||||
|
||||
/** Yeni ürün tipi / düzenle. */
|
||||
export function ProductTypeFormDialog({
|
||||
item,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
item: ProductTypeView | null;
|
||||
categories: StockOption[];
|
||||
defaultCategoryId?: string;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
}) {
|
||||
const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet");
|
||||
const [v, setV] = useState({
|
||||
name: item?.name ?? "",
|
||||
categoryId: item?.categoryId ?? defaultCategoryId ?? "",
|
||||
unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM,
|
||||
customUnit: standard ? "" : (item?.unit ?? ""),
|
||||
targetQuantity: item ? quantityInput(item.targetQuantity) : "",
|
||||
thresholdPct: String(item?.thresholdPct ?? 20),
|
||||
location: item?.location ?? "",
|
||||
step: item ? quantityInput(item.step) : "1",
|
||||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||||
note: item?.note ?? "",
|
||||
initialQuantity: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveProductTypeAction(item?.id ?? null, {
|
||||
name: v.name,
|
||||
categoryId: v.categoryId,
|
||||
unit,
|
||||
targetQuantity: v.targetQuantity,
|
||||
thresholdPct: v.thresholdPct,
|
||||
location: v.location,
|
||||
step: v.step,
|
||||
countEveryDays: v.countEveryDays,
|
||||
note: v.note,
|
||||
initialQuantity: item ? "" : v.initialQuantity,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
const unitSuffix = unit ? ` (${unit})` : "";
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="product-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="product-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="pt-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="pt-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus={!item}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.categoryId)}>
|
||||
<FieldLabel htmlFor="pt-cat">{f.category}</FieldLabel>
|
||||
<Select value={v.categoryId} onValueChange={(x) => set("categoryId", x)}>
|
||||
<SelectTrigger
|
||||
id="pt-cat"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.categoryId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.selectCategory}>
|
||||
{categories.find((c) => c.id === v.categoryId)?.path}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.categoryId ? <FieldError>{errors.categoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-unit">{f.unit}</FieldLabel>
|
||||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||
<SelectTrigger id="pt-unit" className="w-full">
|
||||
<SelectValue>{v.unitChoice === CUSTOM ? f.customUnit : v.unitChoice}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOCK_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>{f.customUnitOption}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && v.unitChoice !== CUSTOM ? (
|
||||
<FieldError>{errors.unit}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-custom">{f.customUnit}</FieldLabel>
|
||||
<Input
|
||||
id="pt-custom"
|
||||
value={v.customUnit}
|
||||
placeholder={f.customUnitPlaceholder}
|
||||
onChange={(e) => set("customUnit", e.target.value)}
|
||||
aria-invalid={Boolean(errors.unit)}
|
||||
/>
|
||||
{errors.unit ? <FieldError>{errors.unit}</FieldError> : null}
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.targetQuantity)}>
|
||||
<FieldLabel htmlFor="pt-target">
|
||||
{f.target}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-target"
|
||||
inputMode="decimal"
|
||||
value={v.targetQuantity}
|
||||
onChange={(e) => set("targetQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.targetQuantity)}
|
||||
/>
|
||||
{errors.targetQuantity ? <FieldError>{errors.targetQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.thresholdPct)}>
|
||||
<FieldLabel htmlFor="pt-threshold">{f.thresholdPct}</FieldLabel>
|
||||
<Input
|
||||
id="pt-threshold"
|
||||
inputMode="numeric"
|
||||
value={v.thresholdPct}
|
||||
onChange={(e) => set("thresholdPct", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.thresholdPct)}
|
||||
/>
|
||||
{errors.thresholdPct ? <FieldError>{errors.thresholdPct}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.targetHint} {f.thresholdHint}
|
||||
</FieldDescription>
|
||||
{!item ? (
|
||||
<Field data-invalid={Boolean(errors.initialQuantity)}>
|
||||
<FieldLabel htmlFor="pt-initial">
|
||||
{f.initialQuantity}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-initial"
|
||||
inputMode="decimal"
|
||||
value={v.initialQuantity}
|
||||
onChange={(e) => set("initialQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.initialQuantity)}
|
||||
/>
|
||||
<FieldDescription>{f.initialHint}</FieldDescription>
|
||||
{errors.initialQuantity ? <FieldError>{errors.initialQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.step)}>
|
||||
<FieldLabel htmlFor="pt-step">
|
||||
{f.step}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-step"
|
||||
inputMode="decimal"
|
||||
value={v.step}
|
||||
onChange={(e) => set("step", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.step)}
|
||||
/>
|
||||
{errors.step ? <FieldError>{errors.step}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="pt-count">{f.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="pt-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.stepHint} {f.countEveryDaysHint}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type MovementKindInput = "purchase" | "consume" | "waste";
|
||||
|
||||
/** Alım, tüketim veya fire. */
|
||||
export function StockMovementDialog({
|
||||
item,
|
||||
initialKind,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
initialKind: MovementKindInput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<MovementKindInput>(initialKind);
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity:
|
||||
initialKind === "purchase" && item.suggestedQuantity
|
||||
? quantityInput(item.suggestedQuantity)
|
||||
: "",
|
||||
wasteReason: "" as WasteReason | "",
|
||||
expiryDate: "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addStockMovementAction(item.id, { kind, ...v });
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.movementSaved);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.movementTitles[kind]} · ${item.name}`}
|
||||
description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
|
||||
footer={<Footer busy={busy} formId="stock-movement-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-movement-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(x) => {
|
||||
setKind(x as MovementKindInput);
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
{(["purchase", "consume", "waste"] as const).map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="sm-qty">
|
||||
{f.quantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sm-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sm-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sm-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{kind === "purchase" ? (
|
||||
<Field data-invalid={Boolean(errors.expiryDate)}>
|
||||
<FieldLabel htmlFor="sm-expiry">{f.expiryDate}</FieldLabel>
|
||||
<Input
|
||||
id="sm-expiry"
|
||||
type="date"
|
||||
value={v.expiryDate}
|
||||
min={v.date}
|
||||
onChange={(e) => set("expiryDate", e.target.value)}
|
||||
/>
|
||||
<FieldDescription>{f.expiryHint}</FieldDescription>
|
||||
{errors.expiryDate ? <FieldError>{errors.expiryDate}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{kind === "waste" ? (
|
||||
<Field data-invalid={Boolean(errors.wasteReason)}>
|
||||
<FieldLabel htmlFor="sm-reason">{f.wasteReason}</FieldLabel>
|
||||
<Select
|
||||
value={v.wasteReason}
|
||||
onValueChange={(x) => set("wasteReason", x as WasteReason)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="sm-reason"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.wasteReason)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.wasteReason}>
|
||||
{v.wasteReason ? t.wasteReasons[v.wasteReason] : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASTE_REASONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{t.wasteReasons[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.wasteReason ? <FieldError>{errors.wasteReason}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sm-note">{f.note}</FieldLabel>
|
||||
<Input id="sm-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi. */
|
||||
export function StockCountDialog({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({ date: todayIstanbul(), actualQuantity: "", note: "" });
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await countStockAction(item.id, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
const diff = (r.data as { diff: number }).diff;
|
||||
if (diff === 0) toast.success(t.toast.noChange);
|
||||
else
|
||||
toast.success(
|
||||
t.toast.counted(`${diff > 0 ? "+" : "−"}${formatStock(Math.abs(diff), item.unit)}`),
|
||||
);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.countTitle} · ${item.name}`}
|
||||
description={t.countDescription}
|
||||
footer={<Footer busy={busy} formId="stock-count-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-count-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.actualQuantity)}>
|
||||
<FieldLabel htmlFor="sc-qty">
|
||||
{f.actualQuantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sc-qty"
|
||||
inputMode="decimal"
|
||||
value={v.actualQuantity}
|
||||
onChange={(e) => set("actualQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.actualQuantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.actualQuantity ? <FieldError>{errors.actualQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sc-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sc-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.expected(formatStock(Math.max(0, item.current), item.unit))}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sc-note">{f.note}</FieldLabel>
|
||||
<Input id="sc-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user