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/scripts/migrations/011-stock.ts b/scripts/migrations/011-stock.ts new file mode 100644 index 0000000..26d455e --- /dev/null +++ b/scripts/migrations/011-stock.ts @@ -0,0 +1,20 @@ +import type { Types } from "mongoose"; +import { installDefaultStockCategories } from "@/features/stock/service"; +import { HouseholdModel } from "@/models/household"; +import { ProductTypeModel } from "@/models/product-type"; +import { StockCategoryModel } from "@/models/stock-category"; +import { StockMovementModel } from "@/models/stock-movement"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "011-stock", + description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri", + async up() { + await StockCategoryModel.createIndexes(); + await ProductTypeModel.createIndexes(); + await StockMovementModel.createIndexes(); + const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean(); + for (const h of households) + await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 8a1b043..0bd3225 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -10,10 +10,23 @@ import { migration as m007 } from "./007-contacts"; import { migration as m008 } from "./008-in-kind"; import { migration as m009 } from "./009-assets"; import { migration as m010 } from "./010-budgets"; +import { migration as m011 } from "./011-stock"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010]; +export const MIGRATIONS: Migration[] = [ + m001, + m002, + m003, + m004, + m005, + m006, + m007, + m008, + m009, + m010, + m011, +]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index a60c5d7..55d6f25 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -34,6 +34,7 @@ describe("seed", () => { expect(summary.created.transactions).toBeGreaterThan(150); expect(summary.created.assets).toBe(4); expect(summary.created.budgets).toBe(2); + expect(summary.created.stock).toBe(19); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -53,6 +54,7 @@ describe("seed", () => { inKind: 0, assets: 0, budgets: 0, + stock: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 165cbfa..633d0d9 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -13,6 +13,7 @@ import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; import { seedBudgets } from "./budgets"; +import { seedStock } from "./stock"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; @@ -55,6 +56,7 @@ export type SeedSummary = { inKind: number; assets: number; budgets: number; + stock: number; }; accounts: SeedAccountRow[]; }; @@ -78,6 +80,9 @@ const DOMAIN_COLLECTIONS = [ "assets", "assetMovements", "budgets", + "stockCategories", + "productTypes", + "stockMovements", ]; type AuthApi = ReturnType["api"]; @@ -133,6 +138,7 @@ export async function runSeed({ inKind: 0, assets: 0, budgets: 0, + stock: 0, }, accounts: [], }; @@ -315,6 +321,8 @@ export async function runSeed({ if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); summary.created.budgets = await seedBudgets(householdIds, userIdMap); if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); + summary.created.stock = await seedStock(householdIds, userIdMap); + if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/stock.ts b/scripts/seed/stock.ts new file mode 100644 index 0000000..f585c64 --- /dev/null +++ b/scripts/seed/stock.ts @@ -0,0 +1,373 @@ +import { Types } from "mongoose"; +import { installDefaultStockCategories } from "@/features/stock/service"; +import { ProductTypeModel } from "@/models/product-type"; +import { StockCategoryModel } from "@/models/stock-category"; +import { StockMovementModel } from "@/models/stock-movement"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import { foldTr } from "@/shared/search"; + +/** + * Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri + * ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, sayım, SKT). Deterministik; + * idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir. + */ + +type SeedProduct = { + ref: string; + category: string; + name: string; + unit: string; + target: number; + /** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */ + pack: number; + perDay: number; + location: string; + step?: number; + threshold?: number; + shelfDays?: number; + /** Her n. alımdan sonra fire (oran %). */ + waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" }; + /** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */ + stopRefillDaysAgo?: number; + /** Her n haftada bir küçük sayım farkı (−%). */ + countDrift?: { everyWeeks: number; pct: number }; +}; + +const K = 1000; + +export const PRODUCTS: SeedProduct[] = [ + // Mutfak › Bakliyat + { + ref: "kirmizi-mercimek", + category: "mutfak.bakliyat", + name: "Kırmızı Mercimek", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 40, + location: "Kiler", + step: 100, + stopRefillDaysAgo: 40, + }, + { + ref: "kuru-fasulye", + category: "mutfak.bakliyat", + name: "Kuru Fasulye", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 25, + location: "Kiler", + step: 250, + }, + { + ref: "nohut", + category: "mutfak.bakliyat", + name: "Nohut", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 20, + location: "Kiler", + step: 250, + }, + { + ref: "pirinc", + category: "mutfak.bakliyat", + name: "Baldo Pirinç", + unit: "kg", + target: 5 * K, + pack: 2_500, + perDay: 90, + location: "Kiler", + step: 500, + }, + // Mutfak › Manav + { + ref: "domates", + category: "mutfak.manav", + name: "Domates", + unit: "kg", + target: 3 * K, + pack: 1 * K, + perDay: 300, + location: "Buzdolabı", + step: 500, + shelfDays: 8, + waste: { every: 2, pct: 30, reason: "spoiled" }, + }, + { + ref: "patates", + category: "mutfak.manav", + name: "Patates", + unit: "kg", + target: 5 * K, + pack: 1 * K, + perDay: 250, + location: "Kiler", + step: 500, + waste: { every: 4, pct: 10, reason: "spoiled" }, + }, + { + ref: "sogan", + category: "mutfak.manav", + name: "Kuru Soğan", + unit: "kg", + target: 3 * K, + pack: 1 * K, + perDay: 120, + location: "Kiler", + step: 500, + }, + // Mutfak › Şarküteri + { + ref: "beyaz-peynir", + category: "mutfak.sarkuteri", + name: "Beyaz Peynir", + unit: "kg", + target: 1 * K, + pack: 500, + perDay: 30, + location: "Buzdolabı", + step: 100, + shelfDays: 45, + }, + { + ref: "yumurta", + category: "mutfak.sarkuteri", + name: "Yumurta", + unit: "adet", + target: 30 * K, + pack: 15 * K, + perDay: 3 * K, + location: "Buzdolabı", + shelfDays: 25, + }, + { + ref: "sut", + category: "mutfak.sarkuteri", + name: "Süt", + unit: "lt", + target: 6 * K, + pack: 1 * K, + perDay: 700, + location: "Buzdolabı", + shelfDays: 12, + waste: { every: 6, pct: 15, reason: "expired" }, + }, + // Genel Temizlik + { + ref: "tablet-deterjan", + category: "genel-temizlik.bulasik", + name: "Tablet Deterjan", + unit: "tablet", + target: 60 * K, + pack: 40 * K, + perDay: 720, + location: "Lavabo altı", + stopRefillDaysAgo: 40, + }, + { + ref: "elde-yikama", + category: "genel-temizlik.bulasik", + name: "Elde Yıkama Deterjanı", + unit: "lt", + target: 1_500, + pack: 750, + perDay: 15, + location: "Lavabo altı", + step: 100, + }, + { + ref: "toz-deterjan", + category: "genel-temizlik.camasir", + name: "Toz Deterjan", + unit: "yıkama", + target: 80 * K, + pack: 50 * K, + perDay: 700, + location: "Banyo dolabı", + countDrift: { everyWeeks: 8, pct: 4 }, + }, + { + ref: "yumusatici", + category: "genel-temizlik.camasir", + name: "Yumuşatıcı", + unit: "lt", + target: 3 * K, + pack: 1_440, + perDay: 40, + location: "Banyo dolabı", + step: 100, + }, + { + ref: "camasir-suyu", + category: "genel-temizlik.genel", + name: "Çamaşır Suyu", + unit: "lt", + target: 3 * K, + pack: 1 * K, + perDay: 25, + location: "Balkon", + step: 250, + }, + { + ref: "tuvalet-kagidi", + category: "genel-temizlik.genel", + name: "Tuvalet Kağıdı", + unit: "rulo", + target: 32 * K, + pack: 16 * K, + perDay: 600, + location: "Banyo dolabı", + countDrift: { everyWeeks: 10, pct: 6 }, + }, + // Kişisel Temizlik + { + ref: "sampuan", + category: "kisisel-temizlik.sampuan", + name: "Şampuan", + unit: "adet", + target: 3 * K, + pack: 1 * K, + perDay: 50, + location: "Banyo dolabı", + }, + { + ref: "tiras-bicagi", + category: "kisisel-temizlik.tiras", + name: "Tıraş Bıçağı", + unit: "adet", + target: 8 * K, + pack: 4 * K, + perDay: 150, + location: "Banyo dolabı", + }, + { + ref: "dis-macunu", + category: "kisisel-temizlik.kisisel-bakim", + name: "Diş Macunu", + unit: "adet", + target: 4 * K, + pack: 2 * K, + perDay: 70, + location: "Banyo dolabı", + stopRefillDaysAgo: 60, + }, +]; + +const START_DAYS_AGO = 182; + +type Row = { + kind: "purchase" | "consume" | "waste" | "count_adjust"; + daysAgo: number; + quantity: number; + expiryDate?: string | null; + wasteReason?: string | null; + note?: string; + initial?: boolean; +}; + +/** Haftalık simülasyon: tüketim, eşikte alım, fire ve sayım farkı. Stok asla eksiye düşmez. */ +export function simulate(p: SeedProduct, today: string): Row[] { + const rows: Row[] = []; + let stock = p.target; + let purchases = 0; + rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true }); + let week = 0; + for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) { + week++; + // Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg). + const step = p.step ?? K; + const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step)); + if (consume > 0) { + rows.push({ kind: "consume", daysAgo: d, quantity: consume }); + stock -= consume; + } + if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) { + const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100)); + if (drift > 0) { + rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" }); + stock -= drift; + } + } + const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo; + if (canRefill && stock < (p.target * 35) / 100) { + const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack)); + const quantity = packs * p.pack; + purchases++; + rows.push({ + kind: "purchase", + daysAgo: d, + quantity, + expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null, + }); + stock += quantity; + if (p.waste && purchases % p.waste.every === 0 && d >= 3) { + const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100)); + rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason }); + stock -= w; + } + } + } + return rows; +} + +export async function seedStock( + householdIds: Map, + users: Map, +): Promise { + const actor = new Types.ObjectId(users.get("ayse")!); + for (const [, id] of householdIds) await installDefaultStockCategories(id, actor); + const householdId = householdIds.get("Çakır Ailesi")!; + const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } }) + .select({ defaultKey: 1 }) + .lean<{ _id: Types.ObjectId; defaultKey: string }[]>(); + const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id])); + const today = todayIstanbul(); + let created = 0; + for (const p of PRODUCTS) { + if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue; + const doc = await ProductTypeModel.create({ + householdId, + categoryId: catByKey.get(p.category)!, + name: p.name, + nameKey: foldTr(p.name), + unit: p.unit, + targetQuantity: p.target, + thresholdPct: p.threshold ?? 20, + location: p.location, + step: p.step ?? K, + seedRef: p.ref, + createdBy: actor, + updatedBy: actor, + }); + const base = Date.now() - START_DAYS_AGO * 86_400_000; + // Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur. + await StockMovementModel.collection.insertMany( + simulate(p, today).map((r, i) => { + const at = new Date(base + i * 1000); + return { + householdId, + productTypeId: doc._id, + kind: r.kind, + date: addDays(today, -r.daysAgo), + quantity: r.quantity, + wasteReason: r.wasteReason ?? null, + expiryDate: r.expiryDate ?? null, + note: r.note ?? "", + initial: r.initial ?? false, + countId: null, + deletedAt: null, + seedRef: `${p.ref}:${i}`, + createdBy: actor, + createdAt: at, + updatedAt: at, + }; + }), + ); + created++; + } + return created; +} 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/households/service.ts b/src/features/households/service.ts index cac42f2..4bd5fef 100644 --- a/src/features/households/service.ts +++ b/src/features/households/service.ts @@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { toObjectId } from "@/lib/object-id"; import { installDefaultCategories } from "@/features/categories/service"; +import { installDefaultStockCategories } from "@/features/stock/service"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import type { HouseholdSettings } from "@/shared/schemas/household"; @@ -324,6 +325,8 @@ export async function adminCreateHousehold( ); // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). await installDefaultCategories(household!._id, actor, session); + // Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden). + await installDefaultStockCategories(household!._id, actor, session); }); return ok({ id: createdId }); } diff --git a/src/features/stock/actions.ts b/src/features/stock/actions.ts new file mode 100644 index 0000000..ee652d5 --- /dev/null +++ b/src/features/stock/actions.ts @@ -0,0 +1,123 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { + productTypeFormSchema, + stockCategoryFormSchema, + stockCountFormSchema, + stockMovementFormSchema, +} from "@/shared/schemas/stock"; +import { + addStockMovement, + countStock, + createProductType, + createStockCategory, + deleteStockMovement, + getStockOptions, + quickConsume, + setProductTypeArchived, + setStockCategoryArchived, + updateProductType, + updateStockCategory, + type StockOption, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +function withId( + raw: unknown, + fn: (id: string) => Promise>, +): Promise> { + const id = idSchema.safeParse(raw); + if (!id.success) return Promise.resolve(fail(tr.common.notFound)); + return fn(id.data); +} + +/** Yeni (id boş) veya mevcut stok kategorisi. */ +export async function saveStockCategoryAction(categoryId: unknown, input: unknown) { + if (categoryId) + return withId(categoryId, (id) => + run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)), + ); + return run(stockCategoryFormSchema, input, createStockCategory); +} + +export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) { + return withId(categoryId, (id) => + run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)), + ); +} + +/** Yeni (id boş) veya mevcut ürün tipi. */ +export async function saveProductTypeAction(productTypeId: unknown, input: unknown) { + if (productTypeId) + return withId(productTypeId, (id) => + run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)), + ); + return run(productTypeFormSchema, input, createProductType); +} + +export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) { + return withId(productTypeId, (id) => + run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)), + ); +} + +export async function addStockMovementAction(productTypeId: unknown, input: unknown) { + return withId(productTypeId, (id) => + run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)), + ); +} + +export async function quickConsumeAction(productTypeId: unknown) { + return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id))); +} + +export async function countStockAction(productTypeId: unknown, input: unknown) { + return withId(productTypeId, (id) => + run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)), + ); +} + +export async function deleteStockMovementAction(movementId: unknown) { + return withId(movementId, (id) => + run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)), + ); +} + +/** Ürün tipi ve stok kategorisi formları için seçenekler. */ +export async function getStockOptionsAction(): Promise< + ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }> +> { + const ctx = await getHouseholdActionContext("viewer"); + if (!ctx) return fail(tr.common.unauthorized); + const data = await getStockOptions(ctx.user.id, ctx.household.id); + return data ? ok(data) : fail(tr.common.unauthorized); +} 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={