feat: Faz 12a - mağazalar ve ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,15 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
|
||||
|
||||
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
|
||||
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
|
||||
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
|
||||
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
|
||||
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
|
||||
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
|
||||
|
||||
## [0.11.0] - 2026-11-06
|
||||
|
||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "013-stores-products",
|
||||
description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri",
|
||||
async up() {
|
||||
await StoreModel.createIndexes();
|
||||
await ProductModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import { migration as m012 } from "./012-stock-counts";
|
||||
import { migration as m013 } from "./013-stores-products";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -28,6 +29,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
m013,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
@@ -35,6 +35,8 @@ describe("seed", () => {
|
||||
expect(summary.created.assets).toBe(4);
|
||||
expect(summary.created.budgets).toBe(2);
|
||||
expect(summary.created.stock).toBe(19);
|
||||
expect(summary.created.stores).toBe(8);
|
||||
expect(summary.created.products).toBe(22);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -55,6 +57,8 @@ describe("seed", () => {
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedShopping } from "./shopping";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
@@ -57,6 +58,8 @@ export type SeedSummary = {
|
||||
assets: number;
|
||||
budgets: number;
|
||||
stock: number;
|
||||
stores: number;
|
||||
products: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -84,6 +87,8 @@ const DOMAIN_COLLECTIONS = [
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
"stores",
|
||||
"products",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -140,6 +145,8 @@ export async function runSeed({
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -324,6 +331,11 @@ export async function runSeed({
|
||||
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}`);
|
||||
const shopping = await seedShopping(householdIds, userIdMap);
|
||||
summary.created.stores = shopping.stores;
|
||||
summary.created.products = shopping.products;
|
||||
if (shopping.stores || shopping.products)
|
||||
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import { autoConvertible } from "@/shared/products";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { StoreKind } from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
|
||||
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
|
||||
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
|
||||
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
|
||||
*/
|
||||
|
||||
type SeedStore = {
|
||||
ref: string;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch?: string;
|
||||
distanceM?: number;
|
||||
carry: number;
|
||||
deliveryFee?: number;
|
||||
freeDeliveryMin?: number;
|
||||
mealCards?: MealCardProvider[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const STORES: SeedStore[] = [
|
||||
{
|
||||
ref: "migros",
|
||||
name: "Migros",
|
||||
kind: "market",
|
||||
branch: "Bahariye",
|
||||
distanceM: 1_200,
|
||||
carry: 2,
|
||||
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
|
||||
},
|
||||
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
|
||||
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
|
||||
{
|
||||
ref: "sok",
|
||||
name: "Şok",
|
||||
kind: "market",
|
||||
branch: "Rasimpaşa",
|
||||
distanceM: 300,
|
||||
carry: 1,
|
||||
mealCards: ["multinet", "pluxee"],
|
||||
},
|
||||
{
|
||||
ref: "carrefoursa",
|
||||
name: "CarrefourSA",
|
||||
kind: "market",
|
||||
branch: "Acıbadem",
|
||||
distanceM: 3_800,
|
||||
carry: 4,
|
||||
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
|
||||
note: "Hafta sonu arabayla; büyük alışveriş.",
|
||||
},
|
||||
{
|
||||
ref: "sali-pazari",
|
||||
name: "Salı Pazarı",
|
||||
kind: "bazaar",
|
||||
branch: "Kadıköy",
|
||||
distanceM: 1_900,
|
||||
carry: 4,
|
||||
note: "Salı ve cuma; sebze-meyve.",
|
||||
},
|
||||
{
|
||||
ref: "getir-buyuk",
|
||||
name: "Getir Büyük",
|
||||
kind: "online",
|
||||
carry: 1,
|
||||
deliveryFee: 39_90,
|
||||
freeDeliveryMin: 750_00,
|
||||
mealCards: ["multinet"],
|
||||
},
|
||||
{
|
||||
ref: "metro",
|
||||
name: "Metro Toptancı",
|
||||
kind: "market",
|
||||
branch: "Kozyatağı",
|
||||
distanceM: 9_500,
|
||||
carry: 5,
|
||||
},
|
||||
];
|
||||
|
||||
type SeedProduct = {
|
||||
ref: string;
|
||||
/** Stok seed'indeki ürün tipi `seedRef`'i. */
|
||||
type: string;
|
||||
brand: string;
|
||||
name?: string;
|
||||
barcode?: string;
|
||||
qty: number;
|
||||
unit: string;
|
||||
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
|
||||
typeQty?: number;
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
|
||||
export const PRODUCTS: SeedProduct[] = [
|
||||
{
|
||||
ref: "ariel-5kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Ariel",
|
||||
name: "Matik Dağ Esintisi",
|
||||
qty: 5 * K,
|
||||
unit: "kg",
|
||||
typeQty: 33 * K,
|
||||
barcode: "8690000100015",
|
||||
},
|
||||
{
|
||||
ref: "omo-6kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Omo",
|
||||
name: "Matik Active Fresh",
|
||||
qty: 6 * K,
|
||||
unit: "kg",
|
||||
typeQty: 40 * K,
|
||||
},
|
||||
{
|
||||
ref: "bingo-4kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Bingo",
|
||||
name: "Matik",
|
||||
qty: 4 * K,
|
||||
unit: "kg",
|
||||
typeQty: 26 * K,
|
||||
},
|
||||
{
|
||||
ref: "fairy-50",
|
||||
type: "tablet-deterjan",
|
||||
brand: "Fairy",
|
||||
name: "Platinum Plus",
|
||||
qty: 1 * K,
|
||||
unit: "paket",
|
||||
typeQty: 50 * K,
|
||||
barcode: "8690000100022",
|
||||
},
|
||||
{
|
||||
ref: "finish-40",
|
||||
type: "tablet-deterjan",
|
||||
brand: "Finish",
|
||||
name: "Quantum",
|
||||
qty: 40 * K,
|
||||
unit: "tablet",
|
||||
},
|
||||
{
|
||||
ref: "yayla-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Yayla",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 1 * K,
|
||||
unit: "kg",
|
||||
},
|
||||
{
|
||||
ref: "reis-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Reis",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 1 * K,
|
||||
unit: "kg",
|
||||
},
|
||||
{
|
||||
ref: "duru-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Duru",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 2_500,
|
||||
unit: "kg",
|
||||
},
|
||||
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
|
||||
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
|
||||
{
|
||||
ref: "pinar-sut",
|
||||
type: "sut",
|
||||
brand: "Pınar",
|
||||
name: "Tam Yağlı Süt",
|
||||
qty: 1 * K,
|
||||
unit: "lt",
|
||||
barcode: "8690000100039",
|
||||
},
|
||||
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
|
||||
{
|
||||
ref: "icim-sut-500",
|
||||
type: "sut",
|
||||
brand: "İçim",
|
||||
name: "Yarım Yağlı Süt",
|
||||
qty: 500 * K,
|
||||
unit: "ml",
|
||||
},
|
||||
{
|
||||
ref: "keskinoglu-30",
|
||||
type: "yumurta",
|
||||
brand: "Keskinoğlu",
|
||||
name: "L Boy 30'lu",
|
||||
qty: 30 * K,
|
||||
unit: "adet",
|
||||
},
|
||||
{
|
||||
ref: "koy-yumurta-15",
|
||||
type: "yumurta",
|
||||
brand: "Köy Yumurtası",
|
||||
name: "15'li",
|
||||
qty: 15 * K,
|
||||
unit: "adet",
|
||||
},
|
||||
{
|
||||
ref: "pinar-peynir",
|
||||
type: "beyaz-peynir",
|
||||
brand: "Pınar",
|
||||
name: "Beyaz Peynir",
|
||||
qty: 500 * K,
|
||||
unit: "g",
|
||||
},
|
||||
{
|
||||
ref: "yumos-1440",
|
||||
type: "yumusatici",
|
||||
brand: "Yumoş",
|
||||
name: "Extra Lilyum",
|
||||
qty: 1_440 * K,
|
||||
unit: "ml",
|
||||
},
|
||||
{
|
||||
ref: "selpak-32",
|
||||
type: "tuvalet-kagidi",
|
||||
brand: "Selpak",
|
||||
name: "32'li",
|
||||
qty: 32 * K,
|
||||
unit: "rulo",
|
||||
},
|
||||
{
|
||||
ref: "solo-24",
|
||||
type: "tuvalet-kagidi",
|
||||
brand: "Solo",
|
||||
name: "24'lü",
|
||||
qty: 24 * K,
|
||||
unit: "rulo",
|
||||
},
|
||||
{
|
||||
ref: "colgate-75",
|
||||
type: "dis-macunu",
|
||||
brand: "Colgate",
|
||||
name: "Total 75 ml",
|
||||
qty: 75 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
{
|
||||
ref: "sensodyne-75",
|
||||
type: "dis-macunu",
|
||||
brand: "Sensodyne",
|
||||
name: "Repair 75 ml",
|
||||
qty: 75 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
{
|
||||
ref: "elidor-500",
|
||||
type: "sampuan",
|
||||
brand: "Elidor",
|
||||
name: "500 ml",
|
||||
qty: 500 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedShopping(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<{ stores: number; products: number }> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
let stores = 0;
|
||||
for (const s of STORES) {
|
||||
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
|
||||
await StoreModel.create({
|
||||
householdId,
|
||||
name: s.name,
|
||||
nameKey: foldTr(s.name),
|
||||
kind: s.kind,
|
||||
branch: s.branch ?? "",
|
||||
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
|
||||
carryDifficulty: s.carry,
|
||||
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
|
||||
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
|
||||
mealCardAccepted: Boolean(s.mealCards?.length),
|
||||
mealCardProviders: s.mealCards ?? [],
|
||||
note: s.note ?? "",
|
||||
seedRef: s.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
stores++;
|
||||
}
|
||||
|
||||
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1, unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
|
||||
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
|
||||
let products = 0;
|
||||
for (const p of PRODUCTS) {
|
||||
const type = typeByRef.get(p.type);
|
||||
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
|
||||
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
|
||||
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
|
||||
await ProductModel.create({
|
||||
householdId,
|
||||
productTypeId: type._id,
|
||||
brand: p.brand,
|
||||
name: p.name ?? "",
|
||||
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
|
||||
barcode: p.barcode ?? null,
|
||||
packageQuantity: p.qty,
|
||||
packageUnit: p.unit,
|
||||
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
|
||||
seedRef: p.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
products++;
|
||||
}
|
||||
return { stores, products };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StoresView } from "@/features/stores/components/stores-view";
|
||||
import { listStores } from "@/features/stores/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stores.title };
|
||||
|
||||
/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
|
||||
export default async function StoresPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listStores(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stores.title} description={tr.stores.description} />
|
||||
{!data.canWrite ? (
|
||||
<p className="text-sm text-muted-foreground">{tr.stores.readOnly}</p>
|
||||
) : null}
|
||||
<StoresView stores={data.stores} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductsSection } from "@/features/products/components/products-section";
|
||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
@@ -11,10 +13,25 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
|
||||
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([
|
||||
const [data, options, products, types] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||
listProductTypeOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return <ProductDetailView data={data} categories={options.categories} />;
|
||||
if (!data || !options || !products || !types) notFound();
|
||||
return (
|
||||
<ProductDetailView
|
||||
data={data}
|
||||
categories={options.categories}
|
||||
products={
|
||||
<ProductsSection
|
||||
productTypeId={data.id}
|
||||
products={products.products}
|
||||
types={types}
|
||||
canWrite={products.canWrite}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductsCatalog } from "@/features/products/components/products-catalog";
|
||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.products.title };
|
||||
|
||||
/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
|
||||
export default async function ProductsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, types] = await Promise.all([
|
||||
listProducts(user.id, household.id, { includeArchived: true }),
|
||||
listProductTypeOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !types) notFound();
|
||||
return <ProductsCatalog products={data.products} types={types} canWrite={data.canWrite} />;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
Tags,
|
||||
Target,
|
||||
UserCog,
|
||||
@@ -27,7 +28,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 = 11.2;
|
||||
export const CURRENT_PHASE = 12.1;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -41,6 +42,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ 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.1 },
|
||||
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.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 },
|
||||
|
||||
@@ -38,11 +38,17 @@ export function AttachmentPicker({
|
||||
onChange,
|
||||
allowedKinds,
|
||||
onBusyChange,
|
||||
hint,
|
||||
addLabel,
|
||||
}: {
|
||||
value: AttachmentInfo[];
|
||||
onChange: (items: AttachmentInfo[]) => void;
|
||||
allowedKinds: readonly AttachmentKind[];
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
|
||||
hint?: string;
|
||||
/** Düğme metni (varsayılan: "Ek ekle"). */
|
||||
addLabel?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
@@ -91,14 +97,16 @@ export function AttachmentPicker({
|
||||
disabled={room <= 0 || uploading > 0}
|
||||
>
|
||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||
{uploading > 0 ? t.uploading : t.add}
|
||||
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{value.length
|
||||
? t.count(value.length)
|
||||
: allowedKinds.includes("pdf")
|
||||
? t.addHint.imagePdf
|
||||
: t.addHint.image}
|
||||
: hint
|
||||
? hint
|
||||
: allowedKinds.includes("pdf")
|
||||
? t.addHint.imagePdf
|
||||
: t.addHint.image}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage";
|
||||
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
detectFileType,
|
||||
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
|
||||
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||
});
|
||||
if (hidden) return null;
|
||||
} else if (a.ownerType === "product") {
|
||||
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
|
||||
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { productFormSchema } from "@/shared/schemas/product";
|
||||
import { createProduct, setProductArchived, updateProduct } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut ürün. */
|
||||
export async function saveProductAction(productId: unknown, input: unknown) {
|
||||
if (productId) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
|
||||
}
|
||||
return run(productFormSchema, input, createProduct);
|
||||
}
|
||||
|
||||
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"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 { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { autoConvertible, packageInTypeUnit } from "@/shared/products";
|
||||
import { formatQuantity, parseQuantity } from "@/shared/quantity";
|
||||
import { quantityInput, STOCK_UNITS } from "@/shared/stock";
|
||||
import { saveProductAction } from "../actions";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
|
||||
const t = tr.products;
|
||||
const CUSTOM = "__custom__";
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
|
||||
export function ProductFormDialog({
|
||||
product,
|
||||
types,
|
||||
defaultTypeId,
|
||||
onClose,
|
||||
}: {
|
||||
product: ProductView | null;
|
||||
types: ProductTypeOption[];
|
||||
defaultTypeId?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialType = product?.productTypeId ?? defaultTypeId ?? "";
|
||||
const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
|
||||
const unitChoices = (typeUnit: string) => [
|
||||
...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
|
||||
];
|
||||
const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
|
||||
const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
|
||||
|
||||
const [v, setV] = useState({
|
||||
productTypeId: initialType,
|
||||
brand: product?.brand ?? "",
|
||||
name: product?.name ?? "",
|
||||
barcode: product?.barcode ?? "",
|
||||
packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
|
||||
unitChoice: known ? initialUnit || "adet" : CUSTOM,
|
||||
customUnit: known ? "" : initialUnit,
|
||||
typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
|
||||
note: product?.note ?? "",
|
||||
});
|
||||
const [images, setImages] = useState<AttachmentInfo[]>(product?.images ?? []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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 typeUnit = typeUnitOf(v.productTypeId);
|
||||
const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||
const needsConversion =
|
||||
Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
|
||||
const pkgQty = parseQuantity(v.packageQuantity);
|
||||
const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
|
||||
const autoValue =
|
||||
pkgQty && typeUnit && !needsConversion
|
||||
? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
|
||||
: null;
|
||||
const f = t.fields;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveProductAction(product?.id ?? null, {
|
||||
productTypeId: v.productTypeId,
|
||||
brand: v.brand,
|
||||
name: v.name,
|
||||
barcode: v.barcode,
|
||||
packageQuantity: v.packageQuantity,
|
||||
packageUnit,
|
||||
typeQuantity: needsConversion ? v.typeQuantity : "",
|
||||
note: v.note,
|
||||
imageIds: images.map((i) => i.id),
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={product ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="product-variant-form" disabled={busy || uploading}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="product-variant-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||
<FieldLabel htmlFor="pr-type">{f.productType}</FieldLabel>
|
||||
<Select
|
||||
value={v.productTypeId}
|
||||
onValueChange={(x) => {
|
||||
set("productTypeId", x);
|
||||
if (!product && v.unitChoice !== CUSTOM) set("unitChoice", typeUnitOf(x) || "adet");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pr-type"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productTypeId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.productType} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{types.map((x) => (
|
||||
<SelectItem key={x.id} value={x.id}>
|
||||
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.brand)}>
|
||||
<FieldLabel htmlFor="pr-brand">{f.brand}</FieldLabel>
|
||||
<Input
|
||||
id="pr-brand"
|
||||
value={v.brand}
|
||||
placeholder={f.brandPlaceholder}
|
||||
onChange={(e) => set("brand", e.target.value)}
|
||||
aria-invalid={Boolean(errors.brand)}
|
||||
autoFocus={!product}
|
||||
/>
|
||||
{errors.brand ? <FieldError>{errors.brand}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="pr-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.packageQuantity)}>
|
||||
<FieldLabel htmlFor="pr-qty">{f.packageQuantity}</FieldLabel>
|
||||
<Input
|
||||
id="pr-qty"
|
||||
inputMode="decimal"
|
||||
value={v.packageQuantity}
|
||||
onChange={(e) => set("packageQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.packageQuantity)}
|
||||
/>
|
||||
{errors.packageQuantity ? <FieldError>{errors.packageQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.packageUnit)}>
|
||||
<FieldLabel htmlFor="pr-unit">{f.packageUnit}</FieldLabel>
|
||||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||
<SelectTrigger id="pr-unit" className="w-full">
|
||||
<SelectValue>
|
||||
{v.unitChoice === CUSTOM ? tr.stock.fields.customUnit : v.unitChoice}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitChoices(typeUnit).map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>{tr.stock.fields.customUnitOption}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Input
|
||||
value={v.customUnit}
|
||||
placeholder={tr.stock.fields.customUnitPlaceholder}
|
||||
aria-label={tr.stock.fields.customUnit}
|
||||
onChange={(e) => set("customUnit", e.target.value)}
|
||||
aria-invalid={Boolean(errors.packageUnit)}
|
||||
/>
|
||||
) : null}
|
||||
{errors.packageUnit ? <FieldError>{errors.packageUnit}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{needsConversion ? (
|
||||
<Field data-invalid={Boolean(errors.typeQuantity)}>
|
||||
<FieldLabel htmlFor="pr-conv">{f.typeQuantity(typeUnit)}</FieldLabel>
|
||||
<Input
|
||||
id="pr-conv"
|
||||
inputMode="decimal"
|
||||
value={v.typeQuantity}
|
||||
onChange={(e) => set("typeQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.typeQuantity)}
|
||||
/>
|
||||
<FieldDescription>{f.typeQuantityHint(pkgText, typeUnit)}</FieldDescription>
|
||||
{errors.typeQuantity ? <FieldError>{errors.typeQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : autoValue !== null && packageUnit !== typeUnit ? (
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.autoConversion(formatQuantity(autoValue, typeUnit))}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.barcode)}>
|
||||
<FieldLabel htmlFor="pr-barcode">{f.barcode}</FieldLabel>
|
||||
<Input
|
||||
id="pr-barcode"
|
||||
inputMode="numeric"
|
||||
value={v.barcode}
|
||||
onChange={(e) => set("barcode", e.target.value)}
|
||||
className="tabular-nums"
|
||||
aria-invalid={Boolean(errors.barcode)}
|
||||
/>
|
||||
<FieldDescription>{f.barcodeHint}</FieldDescription>
|
||||
{errors.barcode ? <FieldError>{errors.barcode}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.imageIds)}>
|
||||
<FieldLabel>{f.images}</FieldLabel>
|
||||
<AttachmentPicker
|
||||
value={images}
|
||||
onChange={setImages}
|
||||
allowedKinds={["image"]}
|
||||
onBusyChange={setUploading}
|
||||
addLabel={f.addImage}
|
||||
hint={f.imagesHint}
|
||||
/>
|
||||
{errors.imageIds ? <FieldError>{errors.imageIds}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-note">{f.note}</FieldLabel>
|
||||
<Input id="pr-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,184 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
AlertTriangle,
|
||||
EllipsisVertical,
|
||||
ImageIcon,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
|
||||
import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { setProductArchivedAction } from "../actions";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
|
||||
export function ProductList({
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
showType = false,
|
||||
}: {
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
/** Katalogda ürün tipi bağlantısı. */
|
||||
showType?: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<ProductView | null>(null);
|
||||
const [archiving, setArchiving] = useState<ProductView | null>(null);
|
||||
const [viewer, setViewer] = useState<ProductView | null>(null);
|
||||
|
||||
async function setArchived(p: ProductView, archived: boolean) {
|
||||
const r = await setProductArchivedAction(p.id, archived);
|
||||
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{products.map((p) => {
|
||||
const cover = p.images[0];
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
className={cn("flex items-center gap-3 p-3", p.archived && "opacity-60")}
|
||||
>
|
||||
{cover ? (
|
||||
<AttachmentThumb item={cover} onClick={() => setViewer(p)} className="size-14" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/40"
|
||||
>
|
||||
<ImageIcon className="size-5 text-muted-foreground/60" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{p.label}</p>
|
||||
<p className="truncate text-sm text-muted-foreground tabular-nums">
|
||||
{p.packageLabel}
|
||||
{showType ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||
{p.productTypeName}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{p.barcode ? <span className="tabular-nums">{t.barcode(p.barcode)}</span> : null}
|
||||
{p.images.length > 1 ? <span>{t.images(p.images.length)}</span> : null}
|
||||
{p.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||
{p.conversionMissing ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
title={t.conversionMissingHint}
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{t.conversionMissing}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${p.label}: ${t.actions.more}`}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={p.archived} onSelect={() => setEditing(p)}>
|
||||
<Pencil />
|
||||
{t.actions.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{p.archived ? (
|
||||
<DropdownMenuItem onSelect={() => setArchived(p, false)}>
|
||||
<ArchiveRestore />
|
||||
{t.actions.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(p)}>
|
||||
<Archive />
|
||||
{t.actions.archive}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{editing ? (
|
||||
<ProductFormDialog product={editing} types={types} onClose={() => setEditing(null)} />
|
||||
) : null}
|
||||
<AttachmentViewer
|
||||
items={viewer?.images ?? []}
|
||||
startIndex={0}
|
||||
open={viewer !== null}
|
||||
onOpenChange={(o) => !o && setViewer(null)}
|
||||
/>
|
||||
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const p = archiving;
|
||||
setArchiving(null);
|
||||
if (p) await setArchived(p, true);
|
||||
}}
|
||||
>
|
||||
{t.actions.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
import { ProductList } from "./product-list";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
|
||||
export function ProductsCatalog({
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
}: {
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const q = foldTr(query.trim());
|
||||
const digits = query.replace(/\s/g, "");
|
||||
const match = (p: ProductView) =>
|
||||
(showArchived || !p.archived) &&
|
||||
(!q ||
|
||||
foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
|
||||
(/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
|
||||
const by = new Map<string, { name: string; items: ProductView[] }>();
|
||||
for (const p of products.filter(match)) {
|
||||
const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
|
||||
g.items.push(p);
|
||||
by.set(p.productTypeId, g);
|
||||
}
|
||||
return [...by.entries()];
|
||||
}, [products, query, showArchived]);
|
||||
|
||||
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>
|
||||
<PageHeader
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
actions={
|
||||
canWrite && types.length ? (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<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.search}
|
||||
aria-label={t.search}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{products.some((p) => p.archived) ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noMatch}</p>
|
||||
) : (
|
||||
groups.map(([typeId, g]) => (
|
||||
<section key={typeId} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
<Link href={`/stok/${typeId}`} className="hover:underline">
|
||||
{g.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<ProductList products={g.items} types={types} canWrite={canWrite} />
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
{creating ? (
|
||||
<ProductFormDialog product={null} types={types} onClose={() => setCreating(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
import { ProductList } from "./product-list";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
|
||||
export function ProductsSection({
|
||||
productTypeId,
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
}: {
|
||||
productTypeId: string;
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<section className="flex flex-col gap-2" aria-labelledby="products-title">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="products-title" className="text-base font-medium">
|
||||
{t.sectionTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.sectionDescription}</p>
|
||||
</div>
|
||||
{canWrite && types.some((x) => x.id === productTypeId) ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.emptyType}
|
||||
</p>
|
||||
) : (
|
||||
<ProductList products={products} types={types} canWrite={canWrite} />
|
||||
)}
|
||||
{creating ? (
|
||||
<ProductFormDialog
|
||||
product={null}
|
||||
types={types}
|
||||
defaultTypeId={productTypeId}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
|
||||
import { env } from "@/lib/env";
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
createProduct,
|
||||
listProducts,
|
||||
listProductTypeOptions,
|
||||
setProductArchived,
|
||||
updateProduct,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await teardownDb();
|
||||
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const typeId = (ref: string) =>
|
||||
ProductTypeModel.findOne({ seedRef: ref })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
|
||||
const png = () =>
|
||||
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
async function form(over: Partial<ProductFormInput> = {}) {
|
||||
return productFormSchema.parse({
|
||||
productTypeId: await typeId("toz-deterjan"),
|
||||
brand: "Persil",
|
||||
name: "Matik",
|
||||
barcode: "",
|
||||
packageQuantity: "4,5",
|
||||
packageUnit: "kg",
|
||||
typeQuantity: "30",
|
||||
note: "",
|
||||
imageIds: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ürünler (marka/paket)", () => {
|
||||
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
|
||||
const all = (await listProducts(ayse, cakir))!;
|
||||
expect(all.products).toHaveLength(22);
|
||||
const ariel = all.products.find((p) => p.brand === "Ariel")!;
|
||||
expect(ariel).toMatchObject({
|
||||
productTypeName: "Toz Deterjan",
|
||||
typeUnit: "yıkama",
|
||||
inTypeUnit: 33_000,
|
||||
packageLabel: "5 kg · 33 yıkama",
|
||||
conversionMissing: false,
|
||||
});
|
||||
const icim = all.products.find((p) => p.brand === "İçim")!;
|
||||
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
|
||||
const peynir = (await listProducts(ayse, cakir, {
|
||||
productTypeId: await typeId("beyaz-peynir"),
|
||||
}))!;
|
||||
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
|
||||
expect(await listProducts(fatma, cakir)).toBeNull();
|
||||
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
|
||||
});
|
||||
|
||||
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
|
||||
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
|
||||
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
|
||||
|
||||
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
|
||||
expect(ok.ok).toBe(true);
|
||||
const sut = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({
|
||||
productTypeId: await typeId("sut"),
|
||||
brand: "Torku",
|
||||
packageQuantity: "1000",
|
||||
packageUnit: "ml",
|
||||
typeQuantity: "7",
|
||||
}),
|
||||
);
|
||||
expect(sut.ok).toBe(true);
|
||||
const list = (await listProducts(ayse, cakir))!.products;
|
||||
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
|
||||
barcode: "8690000000999",
|
||||
inTypeUnit: 30_000,
|
||||
});
|
||||
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
|
||||
typeQuantity: null,
|
||||
inTypeUnit: 1_000,
|
||||
});
|
||||
|
||||
const dup = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Başka", barcode: "8690000000999" }),
|
||||
);
|
||||
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
|
||||
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
|
||||
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
|
||||
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
|
||||
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
|
||||
const id1 = a1.ok ? a1.data.id : "";
|
||||
const id2 = a2.ok ? a2.data.id : "";
|
||||
const r = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Peros", imageIds: [id2, id1] }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const pid = r.ok ? r.data.id : "";
|
||||
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
|
||||
|
||||
// Aynı hanenin başka üyesi görür; başka hane görmez.
|
||||
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
|
||||
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
|
||||
|
||||
// Başkasının yüklediği bekleyen görsel bağlanamaz.
|
||||
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
|
||||
const bad = await updateProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
pid,
|
||||
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images).toHaveLength(2);
|
||||
|
||||
expect(
|
||||
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
|
||||
).toBe(true);
|
||||
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id1]);
|
||||
});
|
||||
|
||||
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
|
||||
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
|
||||
expect(
|
||||
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
|
||||
(p) => p.id === id,
|
||||
)!.archived,
|
||||
).toBe(true);
|
||||
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
|
||||
expect(
|
||||
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
|
||||
).toBe(true);
|
||||
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
syncOwnerAttachments,
|
||||
toAttachmentInfo,
|
||||
type AttachmentInfo,
|
||||
} from "@/features/attachments/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products";
|
||||
import type { ProductForm } from "@/shared/schemas/product";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma:
|
||||
* member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur.
|
||||
* Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel).
|
||||
*/
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
type ProductLean = {
|
||||
_id: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
brand: string;
|
||||
name: string;
|
||||
barcode: string | null;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
note: string;
|
||||
imageCount: number;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null };
|
||||
|
||||
export type ProductView = {
|
||||
id: string;
|
||||
productTypeId: string;
|
||||
productTypeName: string;
|
||||
typeUnit: string;
|
||||
brand: string;
|
||||
name: string;
|
||||
/** "Ariel Matik Dağ Esintisi". */
|
||||
label: string;
|
||||
barcode: string | null;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
/** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */
|
||||
typeQuantity: number | null;
|
||||
/** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */
|
||||
inTypeUnit: number | null;
|
||||
/** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */
|
||||
conversionMissing: boolean;
|
||||
/** "5 kg · 50 yıkama". */
|
||||
packageLabel: string;
|
||||
note: string;
|
||||
archived: boolean;
|
||||
images: AttachmentInfo[];
|
||||
};
|
||||
|
||||
export type ProductTypeOption = { id: string; name: string; unit: string };
|
||||
|
||||
export const productLabel = (p: { brand: string; name: string }) =>
|
||||
p.name ? `${p.brand} ${p.name}` : p.brand;
|
||||
|
||||
function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView {
|
||||
const typeUnit = type?.unit ?? "";
|
||||
const conv = {
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
};
|
||||
const inTypeUnit = packageInTypeUnit(conv);
|
||||
return {
|
||||
id: String(p._id),
|
||||
productTypeId: String(p.productTypeId),
|
||||
productTypeName: type?.name ?? "",
|
||||
typeUnit,
|
||||
brand: p.brand,
|
||||
name: p.name,
|
||||
label: productLabel(p),
|
||||
barcode: p.barcode,
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
inTypeUnit,
|
||||
conversionMissing: inTypeUnit === null,
|
||||
packageLabel: describePackage(conv),
|
||||
note: p.note,
|
||||
archived: Boolean(p.archivedAt),
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await AttachmentModel.find({
|
||||
householdId,
|
||||
ownerType: "product",
|
||||
ownerId: { $in: productIds },
|
||||
deletedAt: null,
|
||||
})
|
||||
.sort({ order: 1, createdAt: 1 })
|
||||
.lean();
|
||||
const by = new Map<string, AttachmentInfo[]>();
|
||||
for (const a of rows) {
|
||||
const key = String(a.ownerId);
|
||||
by.set(key, [
|
||||
...(by.get(key) ?? []),
|
||||
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[0]),
|
||||
]);
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka,
|
||||
* tanım. Arşivliler `includeArchived` ile.
|
||||
*/
|
||||
export async function listProducts(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
opts: { productTypeId?: string; includeArchived?: boolean } = {},
|
||||
): Promise<{ products: ProductView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null;
|
||||
if (opts.productTypeId && !typeId) return null;
|
||||
const rows = await ProductModel.find({
|
||||
householdId: m.householdId,
|
||||
...(typeId ? { productTypeId: typeId } : {}),
|
||||
...(opts.includeArchived ? {} : { archivedAt: null }),
|
||||
}).lean<ProductLean[]>();
|
||||
const types = await ProductTypeModel.find({
|
||||
householdId: m.householdId,
|
||||
_id: {
|
||||
$in: [...new Set(rows.map((r) => String(r.productTypeId)))].map(
|
||||
(id) => new Types.ObjectId(id),
|
||||
),
|
||||
},
|
||||
})
|
||||
.select({ name: 1, unit: 1, archivedAt: 1 })
|
||||
.lean<TypeLean[]>();
|
||||
const typeById = new Map(types.map((x) => [String(x._id), x]));
|
||||
const images = await loadImages(
|
||||
m.householdId,
|
||||
rows.filter((r) => r.imageCount > 0).map((r) => r._id),
|
||||
);
|
||||
const products = rows
|
||||
.map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? []))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
compareTr(a.productTypeName, b.productTypeName) ||
|
||||
compareTr(a.brand, b.brand) ||
|
||||
compareTr(a.name, b.name),
|
||||
);
|
||||
return { products, canWrite: hasHouseholdRole(m.role, "member") };
|
||||
}
|
||||
|
||||
/** Ürün formu için aktif ürün tipleri (ada göre). */
|
||||
export async function listProductTypeOptions(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ProductTypeOption[] | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
|
||||
.select({ name: 1, unit: 1 })
|
||||
.lean<TypeLean[]>();
|
||||
return rows
|
||||
.map((r) => ({ id: String(r._id), name: r.name, unit: r.unit }))
|
||||
.sort((a, b) => compareTr(a.name, b.name));
|
||||
}
|
||||
|
||||
/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */
|
||||
async function checkInput(
|
||||
m: Membership,
|
||||
input: ProductForm,
|
||||
exceptId?: Types.ObjectId,
|
||||
): Promise<{ error: ActionResult<never> } | { error: null; typeQuantity: number | null }> {
|
||||
const type = await ProductTypeModel.findOne({
|
||||
_id: new Types.ObjectId(input.productTypeId),
|
||||
householdId: m.householdId,
|
||||
}).lean<TypeLean>();
|
||||
if (!type || type.archivedAt)
|
||||
return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) };
|
||||
const auto = autoConvertible(input.packageUnit, type.unit);
|
||||
if (!auto && !input.typeQuantity) {
|
||||
const msg = t.validation.conversionRequired(input.packageUnit, type.unit);
|
||||
return { error: fail(msg, { typeQuantity: msg }) };
|
||||
}
|
||||
if (input.barcode) {
|
||||
const dup = await ProductModel.exists({
|
||||
householdId: m.householdId,
|
||||
barcode: input.barcode,
|
||||
archivedAt: null,
|
||||
...(exceptId ? { _id: { $ne: exceptId } } : {}),
|
||||
});
|
||||
if (dup)
|
||||
return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) };
|
||||
}
|
||||
return { error: null, typeQuantity: auto ? null : input.typeQuantity };
|
||||
}
|
||||
|
||||
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
|
||||
class ImageError extends Error {
|
||||
constructor(readonly result: ActionResult<never>) {
|
||||
super(result.ok ? "" : result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(
|
||||
m: Membership,
|
||||
actorId: string,
|
||||
input: ProductForm,
|
||||
typeQuantity: number | null,
|
||||
existing: Types.ObjectId | null,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const fields = {
|
||||
productTypeId: new Types.ObjectId(input.productTypeId),
|
||||
brand: input.brand,
|
||||
name: input.name,
|
||||
searchKey: foldTr(`${input.brand} ${input.name}`),
|
||||
barcode: input.barcode,
|
||||
packageQuantity: input.packageQuantity,
|
||||
packageUnit: input.packageUnit,
|
||||
typeQuantity,
|
||||
note: input.note,
|
||||
updatedBy: actor,
|
||||
};
|
||||
const mongoose = await connectMongoose();
|
||||
let id = existing;
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
if (existing) {
|
||||
await ProductModel.updateOne(
|
||||
{ _id: existing, householdId: m.householdId },
|
||||
{ $set: fields },
|
||||
{ session },
|
||||
);
|
||||
} else {
|
||||
const [doc] = await ProductModel.create(
|
||||
[{ householdId: m.householdId, ...fields, createdBy: actor }],
|
||||
{ session },
|
||||
);
|
||||
id = doc!._id;
|
||||
}
|
||||
const synced = await syncOwnerAttachments({
|
||||
householdId: m.householdId,
|
||||
actorId,
|
||||
ownerType: "product",
|
||||
ownerId: id!,
|
||||
ids: input.imageIds,
|
||||
allowedKinds: ["image"],
|
||||
session,
|
||||
});
|
||||
if (!synced.ok) throw new ImageError(synced);
|
||||
await ProductModel.updateOne(
|
||||
{ _id: id!, householdId: m.householdId },
|
||||
{ $set: { imageCount: synced.data } },
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
return ok({ id: String(id) });
|
||||
}
|
||||
|
||||
export async function createProduct(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ProductForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const c = await checkInput(m, input);
|
||||
if (c.error) return c.error;
|
||||
return save(m, actorId, input, c.typeQuantity, null);
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
input: ProductForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(productId);
|
||||
const current = id
|
||||
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.archivedAt) return fail(t.validation.archived);
|
||||
const c = await checkInput(m, input, current._id);
|
||||
if (c.error) return c.error;
|
||||
return save(m, actorId, input, c.typeQuantity, current._id);
|
||||
}
|
||||
|
||||
export async function setProductArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(productId);
|
||||
const current = id
|
||||
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (!archived) {
|
||||
const type = await ProductTypeModel.exists({
|
||||
_id: current.productTypeId,
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!type) return fail(t.validation.productType);
|
||||
if (
|
||||
current.barcode &&
|
||||
(await ProductModel.exists({
|
||||
householdId: m.householdId,
|
||||
barcode: current.barcode,
|
||||
archivedAt: null,
|
||||
_id: { $ne: current._id },
|
||||
}))
|
||||
)
|
||||
return fail(t.validation.barcodeTaken);
|
||||
}
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ProductModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "product.archive" : "product.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "product",
|
||||
targetId: productId,
|
||||
diff: { label: productLabel(current) },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -78,9 +78,12 @@ function Stat({ label, children }: { label: string; children: React.ReactNode })
|
||||
export function ProductDetailView({
|
||||
data,
|
||||
categories,
|
||||
products,
|
||||
}: {
|
||||
data: ProductTypeDetail;
|
||||
categories: StockOption[];
|
||||
/** Marka/paket bölümü (Faz 12). */
|
||||
products?: React.ReactNode;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
@@ -281,6 +284,8 @@ export function ProductDetailView({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{products}
|
||||
|
||||
<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}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderTree,
|
||||
Plus,
|
||||
Search,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -164,6 +165,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/urunler">
|
||||
<Tags />
|
||||
{tr.products.catalogLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/analiz">
|
||||
<ChartColumn />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { storeFormSchema } from "@/shared/schemas/store";
|
||||
import { createStore, setStoreArchived, updateStore } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut mağaza. */
|
||||
export async function saveStoreAction(storeId: unknown, input: unknown) {
|
||||
if (storeId) {
|
||||
const id = idSchema.safeParse(storeId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(storeFormSchema, input, (u, h, d) => updateStore(u, h, id.data, d));
|
||||
}
|
||||
return run(storeFormSchema, input, createStore);
|
||||
}
|
||||
|
||||
export async function setStoreArchivedAction(storeId: unknown, archived: unknown) {
|
||||
const id = idSchema.safeParse(storeId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.boolean(), archived, (u, h, a) => setStoreArchived(u, h, id.data, a));
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { MEAL_CARD_PROVIDERS, type MealCardProvider } from "@/shared/accounts";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { effortScore, STORE_KINDS, type StoreKind } from "@/shared/stores";
|
||||
import { saveStoreAction } from "../actions";
|
||||
import type { StoreView } from "../service";
|
||||
|
||||
const t = tr.stores;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Yeni mağaza / düzenle. Online mağazada uzaklık yerine teslimat ücretleri. */
|
||||
export function StoreFormDialog({
|
||||
store,
|
||||
onClose,
|
||||
}: {
|
||||
store: StoreView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
name: store?.name ?? "",
|
||||
kind: (store?.kind ?? "market") as StoreKind,
|
||||
branch: store?.branch ?? "",
|
||||
distanceKm: store?.distanceM != null ? quantityInput(store.distanceM) : "",
|
||||
carryDifficulty: String(store?.carryDifficulty ?? 2),
|
||||
deliveryFee: store?.deliveryFeeMinor != null ? formatMoneyInput(store.deliveryFeeMinor) : "",
|
||||
freeDeliveryMin:
|
||||
store?.freeDeliveryMinMinor != null ? formatMoneyInput(store.freeDeliveryMinMinor) : "",
|
||||
mealCardAccepted: store?.mealCardAccepted ?? false,
|
||||
mealCardProviders: (store?.mealCardProviders ?? []) as MealCardProvider[],
|
||||
note: store?.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 }));
|
||||
const online = v.kind === "online";
|
||||
const f = t.fields;
|
||||
|
||||
// Önizleme: girilen uzaklık ve taşıma puanıyla zahmet skoru.
|
||||
const meters = Number(v.distanceKm.replace(",", ".")) * 1000;
|
||||
const preview = effortScore({
|
||||
kind: v.kind,
|
||||
distanceM: Number.isFinite(meters) && v.distanceKm.trim() ? meters : null,
|
||||
carryDifficulty: Number(v.carryDifficulty),
|
||||
});
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveStoreAction(store?.id ?? null, {
|
||||
...v,
|
||||
carryDifficulty: v.carryDifficulty,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const toggleProvider = (p: MealCardProvider, on: boolean) =>
|
||||
set(
|
||||
"mealCardProviders",
|
||||
on ? [...v.mealCardProviders, p] : v.mealCardProviders.filter((x) => x !== p),
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={store ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="store-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="store-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.name)} className="col-span-2 sm:col-span-1">
|
||||
<FieldLabel htmlFor="st-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="st-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus={!store}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field className="col-span-2 sm:col-span-1">
|
||||
<FieldLabel htmlFor="st-branch">{f.branch}</FieldLabel>
|
||||
<Input
|
||||
id="st-branch"
|
||||
value={v.branch}
|
||||
placeholder={f.branchPlaceholder}
|
||||
onChange={(e) => set("branch", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="st-kind">{f.kind}</FieldLabel>
|
||||
<Select value={v.kind} onValueChange={(x) => set("kind", x as StoreKind)}>
|
||||
<SelectTrigger id="st-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{online ? (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.deliveryFee)}>
|
||||
<FieldLabel htmlFor="st-fee">{f.deliveryFee} (₺)</FieldLabel>
|
||||
<Input
|
||||
id="st-fee"
|
||||
inputMode="decimal"
|
||||
value={v.deliveryFee}
|
||||
onChange={(e) => set("deliveryFee", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.deliveryFee)}
|
||||
/>
|
||||
{errors.deliveryFee ? <FieldError>{errors.deliveryFee}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.freeDeliveryMin)}>
|
||||
<FieldLabel htmlFor="st-free">{f.freeDeliveryMin} (₺)</FieldLabel>
|
||||
<Input
|
||||
id="st-free"
|
||||
inputMode="decimal"
|
||||
value={v.freeDeliveryMin}
|
||||
onChange={(e) => set("freeDeliveryMin", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.freeDeliveryMin)}
|
||||
/>
|
||||
{errors.freeDeliveryMin ? (
|
||||
<FieldError>{errors.freeDeliveryMin}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field data-invalid={Boolean(errors.distanceKm)}>
|
||||
<FieldLabel htmlFor="st-distance">{f.distance}</FieldLabel>
|
||||
<Input
|
||||
id="st-distance"
|
||||
inputMode="decimal"
|
||||
value={v.distanceKm}
|
||||
onChange={(e) => set("distanceKm", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.distanceKm)}
|
||||
/>
|
||||
{errors.distanceKm ? <FieldError>{errors.distanceKm}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field className={online ? "col-span-2" : undefined}>
|
||||
<FieldLabel htmlFor="st-carry">{f.carry}</FieldLabel>
|
||||
<Select value={v.carryDifficulty} onValueChange={(x) => set("carryDifficulty", x)}>
|
||||
<SelectTrigger id="st-carry" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} · {t.carryLevels[n]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{online ? null : `${f.distanceHint} `}
|
||||
{f.carryHint} <span className="font-medium">{t.effortValue(preview)}</span>
|
||||
</FieldDescription>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="st-meal"
|
||||
checked={v.mealCardAccepted}
|
||||
onCheckedChange={(x) => set("mealCardAccepted", x === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="st-meal">{f.mealCard}</FieldLabel>
|
||||
</Field>
|
||||
{v.mealCardAccepted ? (
|
||||
<Field data-invalid={Boolean(errors.mealCardProviders)}>
|
||||
<FieldLabel>{f.mealCardProviders}</FieldLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{MEAL_CARD_PROVIDERS.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={v.mealCardProviders.includes(p)}
|
||||
onCheckedChange={(x) => toggleProvider(p, x === true)}
|
||||
/>
|
||||
{tr.accounts.providers[p]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.mealCardProviders ? (
|
||||
<FieldError>{errors.mealCardProviders}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="st-note">{f.note}</FieldLabel>
|
||||
<Input id="st-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,275 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Plus,
|
||||
ShoppingBasket,
|
||||
Store,
|
||||
Utensils,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import {
|
||||
formatDistance,
|
||||
sortStores,
|
||||
STORE_SORTS,
|
||||
type StoreKind,
|
||||
type StoreSort,
|
||||
} from "@/shared/stores";
|
||||
import { setStoreArchivedAction } from "../actions";
|
||||
import type { StoreView } from "../service";
|
||||
import { StoreFormDialog } from "./store-dialog";
|
||||
|
||||
const t = tr.stores;
|
||||
|
||||
const KIND_ICON: Record<StoreKind, typeof Store> = {
|
||||
market: Store,
|
||||
online: Globe,
|
||||
bazaar: ShoppingBasket,
|
||||
};
|
||||
|
||||
/** Zahmet çubuğu: 0–100, düzey etiketiyle (renk tek başına anlam taşımaz). */
|
||||
function EffortMeter({ store }: { store: StoreView }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={t.effortHint}>
|
||||
<span className="w-14 shrink-0 text-xs text-muted-foreground">{t.effort}</span>
|
||||
<div
|
||||
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
|
||||
role="meter"
|
||||
aria-label={t.effortValue(store.effort)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={store.effort}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
store.effortLevel === "high"
|
||||
? "bg-expense"
|
||||
: store.effortLevel === "medium"
|
||||
? "bg-amber-500"
|
||||
: "bg-income",
|
||||
)}
|
||||
style={{ width: `${Math.max(store.effort, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-20 shrink-0 text-right text-xs tabular-nums">
|
||||
{store.effort} · {t.effortLevels[store.effortLevel]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /magazalar: sıralama (ad, en yakın, en uzak, en zahmetli), kartlar, ekle/düzenle/arşivle. */
|
||||
export function StoresView({ stores, canWrite }: { stores: StoreView[]; canWrite: boolean }) {
|
||||
const [sort, setSort] = useState<StoreSort>("name");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [editing, setEditing] = useState<StoreView | "new" | null>(null);
|
||||
const [archiving, setArchiving] = useState<StoreView | null>(null);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
sortStores(
|
||||
stores.filter((s) => showArchived || !s.archived),
|
||||
sort,
|
||||
),
|
||||
[stores, sort, showArchived],
|
||||
);
|
||||
const hasArchived = stores.some((s) => s.archived);
|
||||
|
||||
async function setArchived(store: StoreView, archived: boolean) {
|
||||
const r = await setStoreArchivedAction(store.id, archived);
|
||||
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={sort} onValueChange={(x) => setSort(x as StoreSort)}>
|
||||
<SelectTrigger className="w-44" aria-label={t.sort}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORE_SORTS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t.sorts[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasArchived ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
) : null}
|
||||
{canWrite ? (
|
||||
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
|
||||
<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="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visible.map((s) => {
|
||||
const Icon = KIND_ICON[s.kind];
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-xl border bg-card p-4",
|
||||
s.archived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{s.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={s.archived} onSelect={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
{t.actions.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{s.archived ? (
|
||||
<DropdownMenuItem onSelect={() => setArchived(s, false)}>
|
||||
<ArchiveRestore />
|
||||
{t.actions.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(s)}>
|
||||
<Archive />
|
||||
{t.actions.archive}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{s.kind === "online" ? (
|
||||
<span className="text-muted-foreground">
|
||||
{s.deliveryFeeMinor
|
||||
? t.delivery(formatMoney(s.deliveryFeeMinor))
|
||||
: t.freeDelivery}
|
||||
{s.deliveryFeeMinor && s.freeDeliveryMinMinor
|
||||
? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 tabular-nums">
|
||||
<MapPin className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
{s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]}
|
||||
</span>
|
||||
</div>
|
||||
<EffortMeter store={s} />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{s.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||
{s.mealCardAccepted ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-auto max-w-full gap-1 text-left whitespace-normal"
|
||||
>
|
||||
<Utensils className="size-3" aria-hidden />
|
||||
{t.mealCardBadge}:{" "}
|
||||
{s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{s.note ? <p className="text-xs text-muted-foreground">{s.note}</p> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<StoreFormDialog
|
||||
store={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const s = archiving;
|
||||
setArchiving(null);
|
||||
if (s) await setArchived(s, true);
|
||||
}}
|
||||
>
|
||||
{t.actions.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { storeFormSchema, type StoreFormInput } from "@/shared/schemas/store";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import { createStore, listStores, setStoreArchived, updateStore } from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const form = (over: Partial<StoreFormInput> = {}) =>
|
||||
storeFormSchema.parse({
|
||||
name: "Macrocenter",
|
||||
kind: "market",
|
||||
branch: "Caddebostan",
|
||||
distanceKm: "4,5",
|
||||
carryDifficulty: "3",
|
||||
deliveryFee: "",
|
||||
freeDeliveryMin: "",
|
||||
mealCardAccepted: false,
|
||||
mealCardProviders: [],
|
||||
note: "",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("mağazalar", () => {
|
||||
it("seed: zahmet skoru hesaplanır; izolasyon", async () => {
|
||||
const s = (await listStores(ayse, cakir))!;
|
||||
expect(s.stores).toHaveLength(8);
|
||||
const carrefour = s.stores.find((x) => x.name === "CarrefourSA")!;
|
||||
// 3,8 km → 9,5; taşıma 4 → 37,5 → 47.
|
||||
expect(carrefour).toMatchObject({ effort: 47, effortLevel: "medium", mealCardAccepted: true });
|
||||
const getir = s.stores.find((x) => x.kind === "online")!;
|
||||
expect(getir).toMatchObject({ distanceM: null, effort: 0, deliveryFeeMinor: 39_90 });
|
||||
expect(s.stores.find((x) => x.name === "Metro Toptancı")!.effortLevel).toBe("high");
|
||||
expect(await listStores(fatma, cakir)).toBeNull();
|
||||
expect((await listStores(zeynep, cakir))!.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it("şema: online mağazada uzaklık yok; yemek kartı seçilince sağlayıcı zorunlu", () => {
|
||||
expect(form({ kind: "online", deliveryFee: "29,90", distanceKm: "3" })).toMatchObject({
|
||||
distanceM: null,
|
||||
deliveryFeeMinor: 29_90,
|
||||
});
|
||||
expect(form({ deliveryFee: "29,90" })).toMatchObject({
|
||||
distanceM: 4_500,
|
||||
deliveryFeeMinor: null,
|
||||
});
|
||||
expect(
|
||||
storeFormSchema.safeParse({ ...form(), mealCardAccepted: true, mealCardProviders: [] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
storeFormSchema.safeParse({
|
||||
name: "X",
|
||||
kind: "market",
|
||||
branch: "",
|
||||
distanceKm: "abc",
|
||||
carryDifficulty: "6",
|
||||
deliveryFee: "",
|
||||
freeDeliveryMin: "",
|
||||
mealCardAccepted: false,
|
||||
mealCardProviders: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("oluştur, aynı ad + şube reddi, düzenle, arşiv denetimi", async () => {
|
||||
const r = await createStore(ayse, cakir, form());
|
||||
expect(r.ok).toBe(true);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await createStore(ayse, cakir, form({ name: "macrocenter" }))).ok).toBe(false);
|
||||
// Farklı şube serbest.
|
||||
expect((await createStore(ayse, cakir, form({ branch: "Ataşehir" }))).ok).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await updateStore(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
form({ mealCardAccepted: true, mealCardProviders: ["pluxee", "pluxee"] }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
let s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||
expect(s.mealCardProviders).toEqual(["pluxee"]);
|
||||
expect((await createStore(zeynep, cakir, form({ name: "İzleyici" }))).ok).toBe(false);
|
||||
|
||||
expect((await setStoreArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect(await AuditLogModel.exists({ action: "store.archive", targetId: id })).toBeTruthy();
|
||||
expect((await updateStore(ayse, cakir, id, form())).ok).toBe(false);
|
||||
// Arşivliyken aynı ad + şube yeniden eklenebilir; arşivden çıkarma çakışırsa reddedilir.
|
||||
expect((await createStore(ayse, cakir, form())).ok).toBe(true);
|
||||
expect((await setStoreArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||
s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||
expect(s.archived).toBe(true);
|
||||
// Başka hanenin kimliğiyle işlem yok.
|
||||
expect((await setStoreArchived(fatma, cakir, id, true)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import type { StoreForm } from "@/shared/schemas/store";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import {
|
||||
effortLevel,
|
||||
effortScore,
|
||||
sortStores,
|
||||
type EffortLevel,
|
||||
type StoreKind,
|
||||
} from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member.
|
||||
* Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores).
|
||||
*/
|
||||
|
||||
const t = tr.stores;
|
||||
|
||||
type StoreLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
export type StoreView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
/** "Migros · Kadıköy". */
|
||||
label: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archived: boolean;
|
||||
effort: number;
|
||||
effortLevel: EffortLevel;
|
||||
};
|
||||
|
||||
export const storeLabel = (s: { name: string; branch: string }) =>
|
||||
s.branch ? `${s.name} · ${s.branch}` : s.name;
|
||||
|
||||
function toView(s: StoreLean): StoreView {
|
||||
const effort = effortScore(s);
|
||||
return {
|
||||
id: String(s._id),
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
branch: s.branch,
|
||||
label: storeLabel(s),
|
||||
distanceM: s.distanceM,
|
||||
carryDifficulty: s.carryDifficulty,
|
||||
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||
mealCardAccepted: s.mealCardAccepted,
|
||||
mealCardProviders: s.mealCardProviders,
|
||||
note: s.note,
|
||||
archived: Boolean(s.archivedAt),
|
||||
effort,
|
||||
effortLevel: effortLevel(effort),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listStores(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ stores: StoreView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const rows = await StoreModel.find({ householdId: m.householdId }).lean<StoreLean[]>();
|
||||
return {
|
||||
stores: sortStores(rows.map(toView), "name"),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */
|
||||
async function isDuplicate(
|
||||
householdId: Types.ObjectId,
|
||||
input: Pick<StoreForm, "name" | "branch">,
|
||||
except?: Types.ObjectId,
|
||||
) {
|
||||
const rows = await StoreModel.find({
|
||||
householdId,
|
||||
nameKey: foldTr(input.name),
|
||||
archivedAt: null,
|
||||
...(except ? { _id: { $ne: except } } : {}),
|
||||
})
|
||||
.select({ branch: 1 })
|
||||
.lean<{ branch: string }[]>();
|
||||
return rows.some((r) => foldTr(r.branch) === foldTr(input.branch));
|
||||
}
|
||||
|
||||
const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) });
|
||||
|
||||
export async function createStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (await isDuplicate(m.householdId, input))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await StoreModel.create({
|
||||
householdId: m.householdId,
|
||||
...fields(input),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.archivedAt) return fail(t.validation.archived);
|
||||
if (await isDuplicate(m.householdId, input, current._id))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setStoreArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean<StoreLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (!archived && (await isDuplicate(m.householdId, current, current._id)))
|
||||
return fail(t.validation.duplicate);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "store.archive" : "store.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "store",
|
||||
targetId: storeId,
|
||||
diff: { name: storeLabel(current) },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
+152
@@ -66,6 +66,7 @@ export const tr = {
|
||||
contacts: "Cari hesaplar",
|
||||
savings: "Birikim",
|
||||
stock: "Ev stoğu",
|
||||
stores: "Mağazalar",
|
||||
shopping: "Alışveriş",
|
||||
reports: "Raporlar",
|
||||
settings: "Ayarlar",
|
||||
@@ -1196,6 +1197,157 @@ export const tr = {
|
||||
open: "Ev stoğuna git",
|
||||
},
|
||||
},
|
||||
stores: {
|
||||
title: "Mağazalar",
|
||||
description:
|
||||
"Alışveriş yaptığınız marketler, online platformlar ve pazarlar. Uzaklık ve taşıma güçlüğünden zahmet skoru hesaplanır; alışveriş önerisi bunu dikkate alır.",
|
||||
new: "Yeni mağaza",
|
||||
createTitle: "Yeni mağaza",
|
||||
editTitle: "Mağazayı düzenle",
|
||||
empty: "Henüz mağaza yok. “Yeni mağaza” ile ekleyin.",
|
||||
readOnly: "İzleyici rolündesiniz; mağazaları değiştiremezsiniz.",
|
||||
sort: "Sırala",
|
||||
sorts: {
|
||||
name: "Ada göre",
|
||||
nearest: "En yakın",
|
||||
farthest: "En uzak",
|
||||
effort: "En zahmetli",
|
||||
},
|
||||
showArchived: "Arşivi göster",
|
||||
archived: "Arşivli",
|
||||
kinds: {
|
||||
market: "Fiziksel market",
|
||||
online: "Online platform",
|
||||
bazaar: "Pazar",
|
||||
},
|
||||
kindShort: {
|
||||
market: "Market",
|
||||
online: "Online",
|
||||
bazaar: "Pazar",
|
||||
},
|
||||
fields: {
|
||||
name: "Ad",
|
||||
namePlaceholder: "ör. Migros, A101, Semt pazarı",
|
||||
kind: "Tür",
|
||||
branch: "Şube",
|
||||
branchPlaceholder: "ör. Kadıköy",
|
||||
distance: "Eve uzaklık (km)",
|
||||
distanceHint: "ör. 1,5. Online mağazada gerekmez.",
|
||||
carry: "Taşıma güçlüğü",
|
||||
carryHint: "1 = kolay (kapının önü), 5 = çok zor (uzak, yokuş, araç gerekir).",
|
||||
deliveryFee: "Teslimat ücreti",
|
||||
freeDeliveryMin: "Ücretsiz teslimat alt limiti",
|
||||
mealCard: "Yemek kartı geçerli",
|
||||
mealCardProviders: "Geçerli yemek kartları",
|
||||
note: "Not",
|
||||
},
|
||||
carryLevels: {
|
||||
1: "Kolay",
|
||||
2: "Rahat",
|
||||
3: "Orta",
|
||||
4: "Zor",
|
||||
5: "Çok zor",
|
||||
} as Record<number, string>,
|
||||
effort: "Zahmet",
|
||||
effortLevels: { low: "Düşük", medium: "Orta", high: "Yüksek" },
|
||||
effortValue: (score: number) => `Zahmet ${score}/100`,
|
||||
effortHint:
|
||||
"Zahmet skoru 0–100: uzaklık (20 km'ye kadar) ve taşıma güçlüğü eşit ağırlıkla. Online mağazada uzaklık sayılmaz.",
|
||||
distanceUnknown: "Uzaklık girilmedi",
|
||||
delivery: (fee: string) => `Teslimat ${fee}`,
|
||||
freeDeliveryFrom: (min: string) => `${min} üzeri ücretsiz`,
|
||||
freeDelivery: "Ücretsiz teslimat",
|
||||
mealCardBadge: "Yemek kartı",
|
||||
actions: {
|
||||
edit: "Düzenle",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
more: "Mağaza işlemleri",
|
||||
},
|
||||
archiveTitle: "Mağaza arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” yeni fiyat ve alışverişlerde seçilemez. Geçmiş kayıtları silinmez.`,
|
||||
toast: {
|
||||
saved: "Mağaza kaydedildi.",
|
||||
archived: "Mağaza arşivlendi.",
|
||||
unarchived: "Mağaza arşivden çıkarıldı.",
|
||||
},
|
||||
validation: {
|
||||
distance: "Geçerli bir uzaklık girin (ör. 2,5)",
|
||||
carry: "1 ile 5 arasında seçin",
|
||||
providers: "En az bir yemek kartı seçin",
|
||||
duplicate: "Bu ad ve şubeyle bir mağaza zaten var.",
|
||||
archived: "Mağaza arşivli.",
|
||||
},
|
||||
},
|
||||
products: {
|
||||
title: "Ürünler",
|
||||
description:
|
||||
"Marka ve paket düzeyinde ürünler (ör. “Ariel Matik 5 kg”). Her ürün bir ürün tipine bağlıdır; stok ürün tipinde tutulur, fiyatlar üründe.",
|
||||
catalogLink: "Ürün kataloğu",
|
||||
back: "Ev stoğuna dön",
|
||||
new: "Yeni ürün",
|
||||
createTitle: "Yeni ürün",
|
||||
editTitle: "Ürünü düzenle",
|
||||
sectionTitle: "Ürünler (marka / paket)",
|
||||
sectionDescription:
|
||||
"Bu ürün tipinin markaları ve paketleri. Fiyatlar ve birim fiyat karşılaştırması bu ürünler üzerinden yapılır.",
|
||||
empty: "Henüz ürün yok.",
|
||||
emptyType: "Bu ürün tipine henüz marka/paket eklenmedi.",
|
||||
search: "Marka, tanım veya barkod ara",
|
||||
noMatch: "Aramaya uyan ürün yok.",
|
||||
showArchived: "Arşivi göster",
|
||||
archived: "Arşivli",
|
||||
barcode: (code: string) => `Barkod ${code}`,
|
||||
images: (n: number) => `${n} görsel`,
|
||||
conversionMissing: "Dönüşüm gerekli",
|
||||
conversionMissingHint:
|
||||
"Ürün tipinin birimi değişti; bu paketin karşılığını girmeden fiyatı karşılaştırılamaz.",
|
||||
fields: {
|
||||
productType: "Ürün tipi",
|
||||
brand: "Marka",
|
||||
brandPlaceholder: "ör. Ariel, Fairy, Yayla",
|
||||
name: "Tanım",
|
||||
namePlaceholder: "ör. Matik Dağ Esintisi",
|
||||
barcode: "Barkod",
|
||||
barcodeHint: "İsteğe bağlı, 8–14 rakam.",
|
||||
packageQuantity: "Paket miktarı",
|
||||
packageUnit: "Paket birimi",
|
||||
typeQuantity: (unit: string) => `Bir paket kaç ${unit}?`,
|
||||
typeQuantityHint: (pkg: string, unit: string) =>
|
||||
`${pkg} ürün tipinin birimine (${unit}) kendiliğinden çevrilemiyor; ör. 5 kg = 50 yıkama.`,
|
||||
autoConversion: (pkg: string) => `Bir paket = ${pkg}`,
|
||||
images: "Ürün görselleri",
|
||||
addImage: "Görsel ekle",
|
||||
imagesHint: "Ambalajın önü, arkası veya raf etiketi",
|
||||
note: "Not",
|
||||
},
|
||||
actions: {
|
||||
edit: "Düzenle",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
more: "Ürün işlemleri",
|
||||
},
|
||||
archiveTitle: "Ürün arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” yeni fiyat ve alışveriş listelerinde seçilemez. Görselleri ve geçmişi silinmez.`,
|
||||
toast: {
|
||||
saved: "Ürün kaydedildi.",
|
||||
archived: "Ürün arşivlendi.",
|
||||
unarchived: "Ürün arşivden çıkarıldı.",
|
||||
},
|
||||
validation: {
|
||||
productType: "Aktif bir ürün tipi seçin",
|
||||
barcode: "Barkod 8–14 rakam olmalı",
|
||||
barcodeTaken: "Bu barkod başka bir üründe kayıtlı.",
|
||||
packageQuantity: "Geçerli bir paket miktarı girin (ör. 5 veya 0,5)",
|
||||
packageUnit: "Paket birimi 1–20 karakter olmalı",
|
||||
typeQuantity: "Geçerli bir miktar girin",
|
||||
conversionRequired: (pkgUnit: string, typeUnit: string) =>
|
||||
`${pkgUnit} → ${typeUnit} dönüşümünü girin (bir paket kaç ${typeUnit}?).`,
|
||||
archived: "Ürün arşivli.",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoos
|
||||
import { ATTACHMENT_KINDS } from "@/shared/attachments";
|
||||
|
||||
/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */
|
||||
export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const;
|
||||
export const ATTACHMENT_OWNER_TYPES = ["transaction", "product"] as const;
|
||||
export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number];
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,10 @@ export const AUDIT_ACTIONS = [
|
||||
"stock.product.archive",
|
||||
"stock.product.unarchive",
|
||||
"stock.movement.delete",
|
||||
"store.archive",
|
||||
"store.unarchive",
|
||||
"product.archive",
|
||||
"product.unarchive",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Ürün (marka/varyant, Bölüm 6.11): "Ariel Matik 5 kg / 50 yıkama". Bir ürün tipine bağlıdır;
|
||||
* fiyatlar (Faz 12b) bu seviyede tutulur. Paket miktarı binde bir. `typeQuantity`: paket birimi
|
||||
* ürün tipinin birimine kendiliğinden çevrilemiyorsa bir paketin ürün tipi birimindeki karşılığı
|
||||
* (zorunlu); çevrilebiliyorsa null. Görseller `attachments` (ownerType "product").
|
||||
*/
|
||||
const productSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||
brand: { type: String, required: true, trim: true, maxlength: 60 },
|
||||
/** Tanım (ör. "Matik Dağ Esintisi"). */
|
||||
name: { type: String, default: "", trim: true, maxlength: 120 },
|
||||
/** Arama anahtarı: marka + tanım (Türkçe katlanmış). */
|
||||
searchKey: { type: String, required: true },
|
||||
barcode: { type: String, default: null },
|
||||
packageQuantity: { type: Number, required: true, min: 1 },
|
||||
packageUnit: { type: String, required: true, trim: true, maxlength: 20 },
|
||||
typeQuantity: { type: Number, default: null, min: 1 },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
imageCount: { type: Number, default: 0 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "products" },
|
||||
);
|
||||
|
||||
productSchema.index({ householdId: 1, productTypeId: 1, archivedAt: 1 });
|
||||
productSchema.index(
|
||||
{ householdId: 1, barcode: 1 },
|
||||
{ partialFilterExpression: { barcode: { $type: "string" } } },
|
||||
);
|
||||
productSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type Product = InferSchemaType<typeof productSchema>;
|
||||
|
||||
export const ProductModel: Model<Product> =
|
||||
(models.Product as Model<Product> | undefined) ?? model<Product>("Product", productSchema);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { MEAL_CARD_PROVIDERS } from "@/shared/accounts";
|
||||
import { STORE_KINDS } from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Mağaza (Bölüm 6.11): fiziksel market, online platform veya pazar. Hane bazlı (hane içinde
|
||||
* ortak). Uzaklık metre; teslimat ücretleri kuruş. Zahmet skoru okuma sırasında hesaplanır.
|
||||
*/
|
||||
const storeSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
nameKey: { type: String, required: true },
|
||||
kind: { type: String, enum: STORE_KINDS, required: true },
|
||||
branch: { type: String, default: "", maxlength: 80 },
|
||||
/** Eve uzaklık (metre); online mağazada null. */
|
||||
distanceM: { type: Number, default: null, min: 0 },
|
||||
/** Taşıma güçlüğü 1 (kolay) – 5 (çok zor). */
|
||||
carryDifficulty: { type: Number, default: 1, min: 1, max: 5 },
|
||||
/** Online: teslimat ücreti ve ücretsiz teslimat alt limiti (kuruş). */
|
||||
deliveryFeeMinor: { type: Number, default: null, min: 0 },
|
||||
freeDeliveryMinMinor: { type: Number, default: null, min: 0 },
|
||||
mealCardAccepted: { type: Boolean, default: false },
|
||||
mealCardProviders: { type: [{ type: String, enum: MEAL_CARD_PROVIDERS }], default: [] },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stores" },
|
||||
);
|
||||
|
||||
storeSchema.index({ householdId: 1, archivedAt: 1, nameKey: 1 });
|
||||
storeSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type Store = InferSchemaType<typeof storeSchema>;
|
||||
|
||||
export const StoreModel: Model<Store> =
|
||||
(models.Store as Model<Store> | undefined) ?? model<Store>("Store", storeSchema);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { autoConvertible, BARCODE_PATTERN, describePackage, packageInTypeUnit } from "./products";
|
||||
|
||||
const p = (
|
||||
packageQuantity: number,
|
||||
packageUnit: string,
|
||||
typeUnit: string,
|
||||
typeQuantity: number | null = null,
|
||||
) => ({
|
||||
packageQuantity,
|
||||
packageUnit,
|
||||
typeUnit,
|
||||
typeQuantity,
|
||||
});
|
||||
|
||||
describe("paketin ürün tipi birimindeki karşılığı", () => {
|
||||
it("aynı birim ve aynı boyut kendiliğinden", () => {
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "kg"))).toBe(5_000);
|
||||
expect(packageInTypeUnit(p(500_000, "g", "kg"))).toBe(500);
|
||||
expect(packageInTypeUnit(p(1_500, "lt", "ml"))).toBe(1_500_000);
|
||||
expect(packageInTypeUnit(p(40_000, "tablet", "tablet"))).toBe(40_000);
|
||||
expect(autoConvertible("g", "kg")).toBe(true);
|
||||
expect(autoConvertible("kg", "lt")).toBe(false);
|
||||
expect(autoConvertible("paket", "adet")).toBe(false);
|
||||
});
|
||||
|
||||
it("farklı boyut veya özel birimde elle dönüşüm, yoksa null", () => {
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "yıkama", 50_000))).toBe(50_000);
|
||||
expect(packageInTypeUnit(p(1_000, "paket", "tablet", 40_000))).toBe(40_000);
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "yıkama"))).toBeNull();
|
||||
// Kendiliğinden çevrilebiliyorsa elle girilen yok sayılır.
|
||||
expect(packageInTypeUnit(p(500_000, "g", "kg", 999))).toBe(500);
|
||||
});
|
||||
|
||||
it("paket açıklaması ve barkod", () => {
|
||||
expect(describePackage(p(5_000, "kg", "yıkama", 50_000))).toBe("5 kg · 50 yıkama");
|
||||
expect(describePackage(p(500_000, "g", "kg"))).toBe("500 g · 0,5 kg");
|
||||
expect(describePackage(p(2_000, "kg", "kg"))).toBe("2 kg");
|
||||
expect(BARCODE_PATTERN.test("8690000000001")).toBe(true);
|
||||
expect(BARCODE_PATTERN.test("123")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { convertQuantity, formatQuantity, UNITS, type Unit } from "./quantity";
|
||||
|
||||
/**
|
||||
* Ürün (marka/varyant) motoru (Bölüm 6.11). Bir paketin ürün tipinin birimindeki karşılığı:
|
||||
* - paket birimi ürün tipinin birimiyle aynıysa paket miktarı,
|
||||
* - aynı boyuttaysa (kg↔g, lt↔ml) çevrilmiş miktar,
|
||||
* - değilse elle girilen dönüşüm ("5 kg = 50 yıkama", "1 paket = 40 tablet"); yoksa null
|
||||
* (kayıtta zorunlu; ürün tipinin birimi sonradan değişirse "dönüşüm gerekli" uyarısı).
|
||||
* Miktarlar binde bir.
|
||||
*/
|
||||
|
||||
const isStandard = (u: string): u is Unit => Object.hasOwn(UNITS, u);
|
||||
|
||||
/** Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyor mu? */
|
||||
export function autoConvertible(packageUnit: string, typeUnit: string): boolean {
|
||||
if (packageUnit === typeUnit) return true;
|
||||
return (
|
||||
isStandard(packageUnit) &&
|
||||
isStandard(typeUnit) &&
|
||||
UNITS[packageUnit].dimension === UNITS[typeUnit].dimension
|
||||
);
|
||||
}
|
||||
|
||||
/** Bir paketin ürün tipinin birimindeki miktarı (çevrilemiyorsa null). */
|
||||
export function packageInTypeUnit(input: {
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeUnit: string;
|
||||
/** Elle girilen dönüşüm: bir paket = bu kadar ürün tipi birimi. */
|
||||
typeQuantity: number | null;
|
||||
}): number | null {
|
||||
const { packageQuantity, packageUnit, typeUnit } = input;
|
||||
if (packageUnit === typeUnit) return packageQuantity;
|
||||
if (autoConvertible(packageUnit, typeUnit))
|
||||
return convertQuantity(packageQuantity, packageUnit as Unit, typeUnit as Unit);
|
||||
return input.typeQuantity && input.typeQuantity > 0 ? input.typeQuantity : null;
|
||||
}
|
||||
|
||||
/** "5 kg", dönüşüm varsa "5 kg · 50 yıkama". */
|
||||
export function describePackage(input: {
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}): string {
|
||||
const pkg = formatQuantity(input.packageQuantity, input.packageUnit);
|
||||
if (input.packageUnit === input.typeUnit) return pkg;
|
||||
const inType = packageInTypeUnit(input);
|
||||
return inType === null ? pkg : `${pkg} · ${formatQuantity(inType, input.typeUnit)}`;
|
||||
}
|
||||
|
||||
/** Barkod: 8–14 rakam (EAN-8, UPC-A, EAN-13, GTIN-14). */
|
||||
export const BARCODE_PATTERN = /^\d{8,14}$/;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||
import { BARCODE_PATTERN } from "../products";
|
||||
import { parseQuantity } from "../quantity";
|
||||
|
||||
const v = tr.validation;
|
||||
const s = tr.products.validation;
|
||||
|
||||
const quantity = (message: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
const q = parseQuantity(x);
|
||||
if (q === null || q <= 0) {
|
||||
ctx.addIssue({ code: "custom", message });
|
||||
return z.NEVER;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
/**
|
||||
* Ürün (marka/varyant). Dönüşümün zorunlu olup olmadığı ürün tipinin birimine bağlı olduğundan
|
||||
* serviste denetlenir; burada yalnızca biçim.
|
||||
*/
|
||||
export const productFormSchema = z.object({
|
||||
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i, s.productType),
|
||||
brand: z.string().trim().min(1, tr.common.required).max(60, v.nameMax),
|
||||
name: z.string().trim().max(120).default(""),
|
||||
barcode: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x) => x.replace(/\s/g, ""))
|
||||
.refine((x) => x === "" || BARCODE_PATTERN.test(x), s.barcode)
|
||||
.transform((x) => (x === "" ? null : x)),
|
||||
packageQuantity: quantity(s.packageQuantity),
|
||||
packageUnit: z.string().trim().min(1, s.packageUnit).max(20, s.packageUnit),
|
||||
typeQuantity: z.preprocess(
|
||||
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||
quantity(s.typeQuantity).nullable(),
|
||||
),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
imageIds: z.array(z.string().regex(/^[a-f0-9]{24}$/i)).max(MAX_ATTACHMENTS_PER_RECORD),
|
||||
});
|
||||
export type ProductFormInput = z.input<typeof productFormSchema>;
|
||||
export type ProductForm = z.output<typeof productFormSchema>;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { MEAL_CARD_PROVIDERS } from "../accounts";
|
||||
import { parseMoney } from "../money";
|
||||
import { parseQuantity } from "../quantity";
|
||||
import { STORE_KINDS } from "../stores";
|
||||
|
||||
const v = tr.validation;
|
||||
const s = tr.stores.validation;
|
||||
|
||||
/** Boş → null; dolu → kuruş (≥ 0). */
|
||||
const optionalMoney = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
if (x === "") return null;
|
||||
const minor = parseMoney(x);
|
||||
if (minor === null || minor < 0) {
|
||||
ctx.addIssue({ code: "custom", message: v.money });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
});
|
||||
|
||||
/** "2,5" km → 2500 m (3 ondalık = metre). Boş → null. */
|
||||
const distance = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
if (x === "") return null;
|
||||
const m = parseQuantity(x);
|
||||
if (m === null || m > 1_000_000) {
|
||||
ctx.addIssue({ code: "custom", message: s.distance });
|
||||
return z.NEVER;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
export const storeFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||
kind: z.enum(STORE_KINDS),
|
||||
branch: z.string().trim().max(80).default(""),
|
||||
distanceKm: distance,
|
||||
carryDifficulty: z.coerce.number().int().min(1, s.carry).max(5, s.carry),
|
||||
deliveryFee: optionalMoney,
|
||||
freeDeliveryMin: optionalMoney,
|
||||
mealCardAccepted: z.boolean(),
|
||||
mealCardProviders: z.array(z.enum(MEAL_CARD_PROVIDERS)).max(MEAL_CARD_PROVIDERS.length),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.mealCardAccepted && x.mealCardProviders.length === 0)
|
||||
ctx.addIssue({ code: "custom", path: ["mealCardProviders"], message: s.providers });
|
||||
})
|
||||
.transform((x) => {
|
||||
const online = x.kind === "online";
|
||||
return {
|
||||
name: x.name,
|
||||
kind: x.kind,
|
||||
branch: x.branch,
|
||||
distanceM: online ? null : x.distanceKm,
|
||||
carryDifficulty: x.carryDifficulty,
|
||||
deliveryFeeMinor: online ? x.deliveryFee : null,
|
||||
freeDeliveryMinMinor: online ? x.freeDeliveryMin : null,
|
||||
mealCardAccepted: x.mealCardAccepted,
|
||||
mealCardProviders: x.mealCardAccepted ? [...new Set(x.mealCardProviders)] : [],
|
||||
note: x.note,
|
||||
};
|
||||
});
|
||||
export type StoreFormInput = z.input<typeof storeFormSchema>;
|
||||
export type StoreForm = z.output<typeof storeFormSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effortLevel, effortScore, formatDistance, sortStores, type StoreKind } from "./stores";
|
||||
|
||||
const s = (name: string, kind: StoreKind, distanceM: number | null, carryDifficulty: number) => ({
|
||||
name,
|
||||
branch: "",
|
||||
kind,
|
||||
distanceM,
|
||||
carryDifficulty,
|
||||
});
|
||||
|
||||
describe("zahmet skoru", () => {
|
||||
it("uzaklık ve taşıma eşit ağırlıklı; online uzaklık saymaz", () => {
|
||||
expect(effortScore(s("A", "market", 0, 1))).toBe(0);
|
||||
expect(effortScore(s("A", "market", 20_000, 5))).toBe(100);
|
||||
expect(effortScore(s("A", "market", 50_000, 5))).toBe(100);
|
||||
expect(effortScore(s("A", "market", 2_000, 3))).toBe(30);
|
||||
expect(effortScore(s("A", "bazaar", 1_000, 4))).toBe(40);
|
||||
expect(effortScore(s("A", "online", 30_000, 1))).toBe(0);
|
||||
expect(effortScore(s("A", "market", null, 2))).toBe(13);
|
||||
expect([effortLevel(0), effortLevel(33), effortLevel(34), effortLevel(67)]).toEqual([
|
||||
"low",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sıralama", () => {
|
||||
const list = [
|
||||
s("Migros", "market", 1_200, 2),
|
||||
s("Pazar", "bazaar", 600, 4),
|
||||
s("Getir", "online", null, 1),
|
||||
s("BİM", "market", 300, 2),
|
||||
s("Metro", "market", 9_000, 3),
|
||||
s("Bakkal", "market", null, 1),
|
||||
];
|
||||
const names = (by: Parameters<typeof sortStores>[1]) => sortStores(list, by).map((x) => x.name);
|
||||
|
||||
it("en yakın / en uzak: online ve uzaklığı bilinmeyenler sonda", () => {
|
||||
expect(names("nearest")).toEqual(["BİM", "Pazar", "Migros", "Metro", "Bakkal", "Getir"]);
|
||||
expect(names("farthest")).toEqual(["Metro", "Migros", "Pazar", "BİM", "Bakkal", "Getir"]);
|
||||
});
|
||||
|
||||
it("en zahmetli ve ada göre (Türkçe)", () => {
|
||||
expect(names("effort")).toEqual(["Metro", "Pazar", "Migros", "BİM", "Bakkal", "Getir"]);
|
||||
expect(names("name")).toEqual(["Bakkal", "BİM", "Getir", "Metro", "Migros", "Pazar"]);
|
||||
});
|
||||
|
||||
it("uzaklık gösterimi", () => {
|
||||
expect(formatDistance(800)).toBe("800 m");
|
||||
expect(formatDistance(2_500)).toBe("2,5 km");
|
||||
expect(formatDistance(12_000)).toBe("12 km");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { compareTr } from "./search";
|
||||
|
||||
/**
|
||||
* Mağaza motoru (Bölüm 6.11). Saf fonksiyonlar.
|
||||
*
|
||||
* Zahmet skoru (0–100): uzaklık ve taşıma güçlüğünün eşit ağırlıklı bileşimi.
|
||||
* uzaklık payı = min(uzaklık, 20 km) / 20 km × 50 (online mağazada 0: kapıya teslim)
|
||||
* taşıma payı = (taşıma puanı − 1) / 4 × 50 (1 = kolay … 5 = çok zor)
|
||||
* Faz 13'te akıllı öneri bu skoru maliyet cezasına çevirir.
|
||||
*/
|
||||
|
||||
export const STORE_KINDS = ["market", "online", "bazaar"] as const;
|
||||
export type StoreKind = (typeof STORE_KINDS)[number];
|
||||
|
||||
/** Uzaklık metre cinsinden tamsayı saklanır (2,5 km → 2500). */
|
||||
export const EFFORT_MAX_DISTANCE_M = 20_000;
|
||||
export const CARRY_MIN = 1;
|
||||
export const CARRY_MAX = 5;
|
||||
|
||||
export type StoreEffortInput = {
|
||||
kind: StoreKind;
|
||||
/** Metre; bilinmiyorsa null. */
|
||||
distanceM: number | null;
|
||||
/** 1–5. */
|
||||
carryDifficulty: number;
|
||||
};
|
||||
|
||||
export function effortScore(s: StoreEffortInput): number {
|
||||
const distance =
|
||||
s.kind === "online" ? 0 : Math.min(Math.max(0, s.distanceM ?? 0), EFFORT_MAX_DISTANCE_M);
|
||||
const carry = Math.min(CARRY_MAX, Math.max(CARRY_MIN, s.carryDifficulty));
|
||||
return Math.round(
|
||||
(distance / EFFORT_MAX_DISTANCE_M) * 50 + ((carry - CARRY_MIN) / (CARRY_MAX - CARRY_MIN)) * 50,
|
||||
);
|
||||
}
|
||||
|
||||
export type EffortLevel = "low" | "medium" | "high";
|
||||
|
||||
export function effortLevel(score: number): EffortLevel {
|
||||
if (score < 34) return "low";
|
||||
if (score < 67) return "medium";
|
||||
return "high";
|
||||
}
|
||||
|
||||
export const STORE_SORTS = ["name", "nearest", "farthest", "effort"] as const;
|
||||
export type StoreSort = (typeof STORE_SORTS)[number];
|
||||
|
||||
type Sortable = StoreEffortInput & { name: string; branch: string };
|
||||
|
||||
/**
|
||||
* Sıralama. "En yakın/en uzak" yalnızca uzaklığı bilinen fiziksel mağazaları sıralar; online ve
|
||||
* uzaklığı bilinmeyenler sonda (ada göre). "En zahmetli": skor büyükten küçüğe.
|
||||
*/
|
||||
export function sortStores<T extends Sortable>(stores: readonly T[], by: StoreSort): T[] {
|
||||
const byName = (a: T, b: T) => compareTr(a.name, b.name) || compareTr(a.branch, b.branch);
|
||||
const known = (s: T) => s.kind !== "online" && s.distanceM !== null;
|
||||
return [...stores].sort((a, b) => {
|
||||
switch (by) {
|
||||
case "name":
|
||||
return byName(a, b);
|
||||
case "effort":
|
||||
return effortScore(b) - effortScore(a) || byName(a, b);
|
||||
case "nearest":
|
||||
case "farthest": {
|
||||
if (known(a) !== known(b)) return known(a) ? -1 : 1;
|
||||
if (!known(a)) return byName(a, b);
|
||||
const d = a.distanceM! - b.distanceM!;
|
||||
return (by === "nearest" ? d : -d) || byName(a, b);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 2500 → "2,5 km"; 800 → "800 m". */
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${meters} m`;
|
||||
const km = meters / 1000;
|
||||
return `${km.toLocaleString("tr-TR", { maximumFractionDigits: 1 })} km`;
|
||||
}
|
||||
Reference in New Issue
Block a user