diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f39b0..2ab2316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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ı). diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index b36d3cc..ee2af1c 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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() { + + + ); } diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx new file mode 100644 index 0000000..dfcc8c5 --- /dev/null +++ b/src/app/(app)/stok/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/stok/kategoriler/page.tsx b/src/app/(app)/stok/kategoriler/page.tsx new file mode 100644 index 0000000..dd9b533 --- /dev/null +++ b/src/app/(app)/stok/kategoriler/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/src/app/(app)/stok/page.tsx b/src/app/(app)/stok/page.tsx new file mode 100644 index 0000000..982f939 --- /dev/null +++ b/src/app/(app)/stok/page.tsx @@ -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 ( + <> + + {!data.canWrite ?

{tr.stock.readOnly}

: null} + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index d5f42f2..3b712d8 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -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 }, diff --git a/src/features/stock/components/low-stock-card.tsx b/src/features/stock/components/low-stock-card.tsx new file mode 100644 index 0000000..e00b39b --- /dev/null +++ b/src/features/stock/components/low-stock-card.tsx @@ -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 ( + + + {t.dashboard.title} + + {t.dashboard.description(data.suggestions.length, data.expiring.length)} + + + +
    + {low.map((i) => ( +
  • + + {i.name} + + + {formatStock(Math.max(0, i.current), i.unit)} + + {" · "} + {t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))} + + +
  • + ))} + {expiring.map(({ item, lot }) => ( +
  • + + {item.name} + + + + {t.expiring.on( + new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format( + new Date(`${lot.expiryDate}T12:00:00+03:00`), + ), + )} + +
  • + ))} +
+ {data.suggestions.length > low.length ? ( +

+ {t.dashboard.more(data.suggestions.length - low.length)} +

+ ) : null} + +
+
+ ); +} diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx new file mode 100644 index 0000000..ce9a43e --- /dev/null +++ b/src/features/stock/components/product-detail-view.tsx @@ -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 ( +
+

{label}

+
{children}
+
+ ); +} + +/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */ +export function ProductDetailView({ + data, + categories, +}: { + data: ProductTypeDetail; + categories: StockOption[]; +}) { + const [pending, setPending] = useState(null); + const close = () => setPending(null); + const q = (x: number) => formatStock(x, data.unit); + const active = data.canWrite && !data.archived; + + return ( +
+ + + + + + + setPending({ kind: "edit" })} + > + + {tr.common.edit} + + + {data.archived ? ( + { + const r = await setProductTypeArchivedAction(data.id, false); + if (r.ok) toast.success(t.toast.unarchived); + else toast.error(r.error); + }} + > + + {t.detail.unarchive} + + ) : ( + setPending({ kind: "archive" })} + > + + {t.detail.archive} + + )} + + + ) : null + } + /> +
+ {data.archived ? {t.detail.archived} : null} + +
+ + + +
+ + + {q(Math.max(0, data.current))} + + + {q(data.targetQuantity)} + + {q(data.level.thresholdQuantity)}{" "} + + (%{data.thresholdPct}) + + + {q(data.suggestedQuantity)} +
+ + {data.location ? ( +

+ {t.detail.location}: {data.location} +

+ ) : null} + {data.note ?

{data.note}

: null} + {active ? ( +
+ + + + + +
+ ) : null} +
+
+ + + + {t.detail.lots} +

{t.detail.lotsHint}

+
+ + {data.lots.length === 0 ? ( +

{t.detail.noLots}

+ ) : ( +
    + {data.lots.map((lot, i) => ( +
  • + + {q(lot.remaining)} + · {ymdLabel(lot.date)} + + {lot.expiryDate && lot.status && lot.status !== "ok" ? ( + + ) : ( + + {lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry} + + )} +
  • + ))} +
+ )} +
+
+ +
+

+ {t.detail.movements} +

+ {data.movements.length === 0 ? ( +

+ {t.detail.noMovements} +

+ ) : ( +
    + {data.movements.map((row) => ( +
  • +
    +

    + {row.initial ? t.initial : t.kinds[row.kind]} + 0 ? "text-income" : "text-expense")} + > + {row.delta > 0 ? "+" : "−"} + {q(Math.abs(row.delta))} + +

    +

    + {ymdLabel(row.date)} + {row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""} + {row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""} + {row.note ? ` · ${row.note}` : ""} +

    +
    + + {t.detail.after(q(row.quantityAfter))} + + {data.canWrite ? ( + + ) : null} +
  • + ))} +
+ )} +
+ + {pending?.kind === "edit" ? ( + + ) : null} + {pending?.kind === "movement" ? ( + + ) : null} + {pending?.kind === "count" ? : null} + !o && close()} + > + + + + {pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle} + + + {pending?.kind === "archive" + ? t.detail.archiveDescription(data.name) + : t.detail.deleteMovementDescription} + + + + {tr.common.cancel} + + + + +
+ ); +} diff --git a/src/features/stock/components/stock-categories-manager.tsx b/src/features/stock/components/stock-categories-manager.tsx new file mode 100644 index 0000000..97a82fa --- /dev/null +++ b/src/features/stock/components/stock-categories-manager.tsx @@ -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; + +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(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 ( +
+ +
+ + {canWrite ? ( + + ) : null} +
+ + {visible.length === 0 ? ( +

+ {t.empty} +

+ ) : ( +
    + {visible.map((c) => ( +
  • +
    +

    + {c.name} + {c.isDefault ? {t.defaultBadge} : null} + {c.archived ? {t.archived} : null} +

    +

    + {[ + 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(" · ")} +

    +
    + {canWrite ? ( + + + + + + {c.archived ? ( + unarchive(c)}> + + {t.unarchive} + + ) : ( + <> + setPending({ kind: "create", parentId: c.id })} + > + + {t.addChild} + + setPending({ kind: "edit", category: c })} + > + + {t.edit} + + + setPending({ kind: "archive", category: c })} + > + + {t.archive} + + + )} + + + ) : null} +
  • + ))} +
+ )} + + {pending?.kind === "create" || pending?.kind === "edit" ? ( + + ) : null} + !o && close()}> + + + {t.archiveTitle} + + {pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null} + + + + {tr.common.cancel} + + + + +
+ ); +} + +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({}); + const [busy, setBusy] = useState(false); + const set = (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 ( + !o && onClose()} + title={category ? t.editTitle : t.createTitle} + footer={ + <> + + + + } + > +
+ + + {t.name} + set("name", e.target.value)} + aria-invalid={Boolean(errors.name)} + autoFocus + /> + {errors.name ? {errors.name} : null} + + + {t.parent} + + {errors.parentId ? {errors.parentId} : null} + + + {t.expenseCategory} + + {t.expenseHint} + {errors.expenseCategoryId ? {errors.expenseCategoryId} : null} + + + {t.countEveryDays} + set("countEveryDays", e.target.value)} + className="w-32 text-right tabular-nums" + aria-invalid={Boolean(errors.countEveryDays)} + /> + {t.countEveryDaysHint} + {errors.countEveryDays ? {errors.countEveryDays} : null} + + {errors.root ? {errors.root} : null} + +
+
+ ); +} diff --git a/src/features/stock/components/stock-dialogs.tsx b/src/features/stock/components/stock-dialogs.tsx new file mode 100644 index 0000000..32b78bc --- /dev/null +++ b/src/features/stock/components/stock-dialogs.tsx @@ -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; +const CUSTOM = "__custom__"; + +function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) { + return ( + <> + + + + ); +} + +function errorsOf(r: { fieldErrors?: Partial>; 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({}); + const [busy, setBusy] = useState(false); + const set = (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 ( + !o && onClose()} + title={item ? t.editTitle : t.createTitle} + footer={