feat: Faz 13a - alışveriş listeleri, akıllı mağaza önerisi ve alışveriş modu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,21 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||||
|
|
||||||
|
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||||
|
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
|
||||||
|
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
|
||||||
|
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
|
||||||
|
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
|
||||||
|
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
|
||||||
|
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
|
||||||
|
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 13a
|
||||||
|
|
||||||
|
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
|
||||||
|
|
||||||
## [0.12.0] - 2026-11-06
|
## [0.12.0] - 2026-11-06
|
||||||
|
|
||||||
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
|
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "015-shopping-lists",
|
||||||
|
description: "alışveriş listeleri (shoppingLists) index'leri",
|
||||||
|
async up() {
|
||||||
|
await ShoppingListModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import { migration as m011 } from "./011-stock";
|
|||||||
import { migration as m012 } from "./012-stock-counts";
|
import { migration as m012 } from "./012-stock-counts";
|
||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
import { migration as m014 } from "./014-product-prices";
|
import { migration as m014 } from "./014-product-prices";
|
||||||
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -32,6 +33,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m012,
|
m012,
|
||||||
m013,
|
m013,
|
||||||
m014,
|
m014,
|
||||||
|
m015,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("seed", () => {
|
|||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
prices: 0,
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
});
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
|
|||||||
+7
-1
@@ -13,7 +13,7 @@ import { runMigrations } from "../migrations";
|
|||||||
import { seedContacts } from "./contacts";
|
import { seedContacts } from "./contacts";
|
||||||
import { seedAssets } from "./assets";
|
import { seedAssets } from "./assets";
|
||||||
import { seedBudgets } from "./budgets";
|
import { seedBudgets } from "./budgets";
|
||||||
import { seedProductPrices, seedShopping } from "./shopping";
|
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
|
||||||
import { seedStock } from "./stock";
|
import { seedStock } from "./stock";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
@@ -61,6 +61,7 @@ export type SeedSummary = {
|
|||||||
stores: number;
|
stores: number;
|
||||||
products: number;
|
products: number;
|
||||||
prices: number;
|
prices: number;
|
||||||
|
shoppingLists: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -91,6 +92,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"stores",
|
"stores",
|
||||||
"products",
|
"products",
|
||||||
"productPrices",
|
"productPrices",
|
||||||
|
"shoppingLists",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -150,6 +152,7 @@ export async function runSeed({
|
|||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
prices: 0,
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -341,6 +344,9 @@ export async function runSeed({
|
|||||||
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||||
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
||||||
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
||||||
|
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
|
||||||
|
if (summary.created.shoppingLists)
|
||||||
|
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
|
|||||||
import { ProductModel } from "@/models/product";
|
import { ProductModel } from "@/models/product";
|
||||||
import { ProductPriceModel } from "@/models/product-price";
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
import { StoreModel } from "@/models/store";
|
import { StoreModel } from "@/models/store";
|
||||||
import type { MealCardProvider } from "@/shared/accounts";
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
import { autoConvertible } from "@/shared/products";
|
import { autoConvertible } from "@/shared/products";
|
||||||
@@ -492,3 +493,88 @@ export async function seedProductPrices(
|
|||||||
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
||||||
return docs.length;
|
return docs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SeedListItem =
|
||||||
|
| { type: string; qty?: number }
|
||||||
|
| { product: string; qty?: number }
|
||||||
|
| { text: string; qty?: number; unit?: string; note?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
|
||||||
|
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
|
||||||
|
* akıllı öneride "fiyatı bilinmeyen" örneği.
|
||||||
|
*/
|
||||||
|
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
|
||||||
|
{
|
||||||
|
ref: "haftalik",
|
||||||
|
name: "Haftalık market",
|
||||||
|
items: [
|
||||||
|
{ type: "sut", qty: 4 * K },
|
||||||
|
{ type: "yumurta", qty: 30 * K },
|
||||||
|
{ type: "pirinc", qty: 2_500 },
|
||||||
|
{ type: "beyaz-peynir", qty: 500 },
|
||||||
|
{ type: "domates", qty: 2 * K },
|
||||||
|
{ type: "toz-deterjan", qty: 40 * K },
|
||||||
|
{ type: "tuvalet-kagidi", qty: 32 * K },
|
||||||
|
{ product: "yumos-1440", qty: 1 * K },
|
||||||
|
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "ay-sonu",
|
||||||
|
name: "Ay sonu toptan",
|
||||||
|
note: "Metro'ya arabayla.",
|
||||||
|
items: [{ type: "camasir-suyu", qty: 4 * K }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedShoppingLists(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
users: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
const [types, products] = await Promise.all([
|
||||||
|
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
]);
|
||||||
|
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
|
||||||
|
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
|
||||||
|
let created = 0;
|
||||||
|
for (const l of SHOPPING_LISTS) {
|
||||||
|
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
|
||||||
|
const items = l.items.flatMap((i): Record<string, unknown>[] => {
|
||||||
|
const common = {
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
quantity: i.qty ?? null,
|
||||||
|
source: "manual",
|
||||||
|
addedBy: actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
};
|
||||||
|
if ("type" in i) {
|
||||||
|
const id = typeByRef.get(i.type);
|
||||||
|
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
|
||||||
|
}
|
||||||
|
if ("product" in i) {
|
||||||
|
const id = productByRef.get(i.product);
|
||||||
|
return id ? [{ ...common, kind: "product", productId: id }] : [];
|
||||||
|
}
|
||||||
|
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
|
||||||
|
});
|
||||||
|
await ShoppingListModel.create({
|
||||||
|
householdId,
|
||||||
|
name: l.name,
|
||||||
|
note: l.note ?? "",
|
||||||
|
items,
|
||||||
|
seedRef: l.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|||||||
+16
-1
@@ -33,6 +33,8 @@ type SeedProduct = {
|
|||||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
||||||
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||||
stopRefillDaysAgo?: number;
|
stopRefillDaysAgo?: number;
|
||||||
|
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
|
||||||
|
bulky?: boolean;
|
||||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||||
countDrift?: { everyCounts: number; pct: number };
|
countDrift?: { everyCounts: number; pct: number };
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "toz-deterjan",
|
ref: "toz-deterjan",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Toz Deterjan",
|
name: "Toz Deterjan",
|
||||||
unit: "yıkama",
|
unit: "yıkama",
|
||||||
@@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "yumusatici",
|
ref: "yumusatici",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Yumuşatıcı",
|
name: "Yumuşatıcı",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "camasir-suyu",
|
ref: "camasir-suyu",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Çamaşır Suyu",
|
name: "Çamaşır Suyu",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "tuvalet-kagidi",
|
ref: "tuvalet-kagidi",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Tuvalet Kağıdı",
|
name: "Tuvalet Kağıdı",
|
||||||
unit: "rulo",
|
unit: "rulo",
|
||||||
@@ -359,7 +365,15 @@ export async function seedStock(
|
|||||||
const today = todayIstanbul();
|
const today = todayIstanbul();
|
||||||
let created = 0;
|
let created = 0;
|
||||||
for (const p of PRODUCTS) {
|
for (const p of PRODUCTS) {
|
||||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
|
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
|
||||||
|
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
|
||||||
|
if (p.bulky)
|
||||||
|
await ProductTypeModel.updateOne(
|
||||||
|
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
|
||||||
|
{ $set: { bulky: true } },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const doc = await ProductTypeModel.create({
|
const doc = await ProductTypeModel.create({
|
||||||
householdId,
|
householdId,
|
||||||
categoryId: catByKey.get(p.category)!,
|
categoryId: catByKey.get(p.category)!,
|
||||||
@@ -370,6 +384,7 @@ export async function seedStock(
|
|||||||
thresholdPct: p.threshold ?? 20,
|
thresholdPct: p.threshold ?? 20,
|
||||||
location: p.location,
|
location: p.location,
|
||||||
step: p.step ?? K,
|
step: p.step ?? K,
|
||||||
|
bulky: p.bulky ?? false,
|
||||||
seedRef: p.ref,
|
seedRef: p.ref,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ShoppingModeView } from "@/features/shopping/components/shopping-mode";
|
||||||
|
import { getShoppingList } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.mode.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id/mod — alışverişe çıkış: mağazaya göre gruplu kontrol listesi. */
|
||||||
|
export default async function ShoppingModePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps<"/alisveris/[id]/mod">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const [{ id }, query] = await Promise.all([params, searchParams]);
|
||||||
|
const data = await getShoppingList(user.id, household.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<ShoppingModeView data={data} choice={query.plan === "single" ? "single" : "recommended"} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ShoppingListDetailView } from "@/features/shopping/components/list-detail";
|
||||||
|
import { getShoppingList } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id — liste kalemleri, stok önerileri ve akıllı mağaza önerisi. */
|
||||||
|
export default async function ShoppingListPage({ params }: PageProps<"/alisveris/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getShoppingList(user.id, household.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return <ShoppingListDetailView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ShoppingListsView } from "@/features/shopping/components/lists-view";
|
||||||
|
import { listShoppingLists } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.title };
|
||||||
|
|
||||||
|
/** /alisveris — aktif ve tamamlanan alışveriş listeleri. */
|
||||||
|
export default async function ShoppingPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const data = await listShoppingLists(user.id, household.id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.shopping.title} description={tr.shopping.description} />
|
||||||
|
{!data.canWrite ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{tr.shopping.readOnly}</p>
|
||||||
|
) : null}
|
||||||
|
<ShoppingListsView
|
||||||
|
active={data.active}
|
||||||
|
completed={data.completed}
|
||||||
|
suggestions={data.suggestions}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
|
|||||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
import { getNetWorth } from "@/features/net-worth/service";
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
|
import { ShoppingCard } from "@/features/shopping/components/shopping-card";
|
||||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -106,9 +107,14 @@ export default async function DashboardPage() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
<LowStockCard userId={user.id} householdId={household.id} />
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<LowStockCard userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ShoppingCard userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 12.2;
|
export const CURRENT_PHASE = 13.1;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||||
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
|
||||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -293,17 +293,35 @@ export async function getTypePriceComparison(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||||||
|
export type TypeOffer = {
|
||||||
|
productId: string;
|
||||||
|
productTypeId: string;
|
||||||
|
storeId: string;
|
||||||
|
priceMinor: number;
|
||||||
|
inTypeUnit: number;
|
||||||
|
unitPriceMinor: number;
|
||||||
|
date: string;
|
||||||
|
stale: boolean;
|
||||||
|
isPromo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||||||
*/
|
*/
|
||||||
export async function latestTypeUnitPrices(
|
export async function loadTypeOffers(
|
||||||
householdId: Types.ObjectId,
|
householdId: Types.ObjectId,
|
||||||
productTypeIds: Types.ObjectId[],
|
productTypeIds: Types.ObjectId[],
|
||||||
today = todayIstanbul(),
|
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
): Promise<TypeOffer[]> {
|
||||||
const [products, types] = await Promise.all([
|
if (!productTypeIds.length) return [];
|
||||||
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
|
const [products, types, archivedStores] = await Promise.all([
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: productTypeIds },
|
||||||
|
...(activeOnly ? { archivedAt: null } : {}),
|
||||||
|
})
|
||||||
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||||
.lean<
|
.lean<
|
||||||
{
|
{
|
||||||
@@ -317,7 +335,13 @@ export async function latestTypeUnitPrices(
|
|||||||
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||||
.select({ unit: 1 })
|
.select({ unit: 1 })
|
||||||
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||||
|
activeOnly
|
||||||
|
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId }[]>()
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||||||
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||||
const prices = await loadPrices(
|
const prices = await loadPrices(
|
||||||
householdId,
|
householdId,
|
||||||
@@ -325,11 +349,12 @@ export async function latestTypeUnitPrices(
|
|||||||
);
|
);
|
||||||
const groups = new Map<string, PriceLean[]>();
|
const groups = new Map<string, PriceLean[]>();
|
||||||
for (const r of prices) {
|
for (const r of prices) {
|
||||||
|
if (skipStore.has(String(r.storeId))) continue;
|
||||||
const key = `${r.productId}:${r.storeId}`;
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
}
|
}
|
||||||
const productById = new Map(products.map((p) => [String(p._id), p]));
|
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||||
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
const out: TypeOffer[] = [];
|
||||||
for (const records of groups.values()) {
|
for (const records of groups.values()) {
|
||||||
const p = productById.get(String(records[0]!.productId))!;
|
const p = productById.get(String(records[0]!.productId))!;
|
||||||
const typeId = String(p.productTypeId);
|
const typeId = String(p.productTypeId);
|
||||||
@@ -341,12 +366,35 @@ export async function latestTypeUnitPrices(
|
|||||||
});
|
});
|
||||||
if (!inType) continue;
|
if (!inType) continue;
|
||||||
const eff = effectivePrice(records, today)!;
|
const eff = effectivePrice(records, today)!;
|
||||||
const prev = out.get(typeId);
|
out.push({
|
||||||
if (!prev || eff.record.date > prev.date)
|
productId: String(p._id),
|
||||||
out.set(typeId, {
|
productTypeId: typeId,
|
||||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
storeId: String(records[0]!.storeId),
|
||||||
date: eff.record.date,
|
priceMinor: eff.record.priceMinor,
|
||||||
});
|
inTypeUnit: inType,
|
||||||
|
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||||
|
date: eff.record.date,
|
||||||
|
stale: eff.stale,
|
||||||
|
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||||
|
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||||
|
*/
|
||||||
|
export async function latestTypeUnitPrices(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeIds: Types.ObjectId[],
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||||
|
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||||
|
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||||||
|
const prev = out.get(o.productTypeId);
|
||||||
|
if (!prev || o.date > prev.date)
|
||||||
|
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"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 {
|
||||||
|
shoppingItemFormSchema,
|
||||||
|
shoppingItemUpdateSchema,
|
||||||
|
shoppingListFormSchema,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
updateShoppingList,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema])));
|
||||||
|
|
||||||
|
export async function saveShoppingListAction(listId: string | null, input: unknown) {
|
||||||
|
if (listId === null)
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d));
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShoppingListAction(listId: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItemAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemUpdateSchema, input, (u, h, d) =>
|
||||||
|
updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItemAction(listId: unknown, itemId: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShoppingItemCheckedAction(
|
||||||
|
listId: unknown,
|
||||||
|
itemId: unknown,
|
||||||
|
checked: unknown,
|
||||||
|
) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) {
|
||||||
|
const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds);
|
||||||
|
if (!id.success || !types.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestionAction(productTypeId: unknown) {
|
||||||
|
const id = idSchema.safeParse(productTypeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"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,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { SHOPPING_ITEM_KINDS, type ShoppingItemKind } from "@/shared/shopping";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { addShoppingItemAction, updateShoppingItemAction } from "../actions";
|
||||||
|
import type { ItemOptions, ShoppingItemView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const f = t.fields;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Kalem ekle (ürün tipi / belirli marka / serbest metin) ya da düzenle (miktar, not). */
|
||||||
|
export function ShoppingItemDialog({
|
||||||
|
listId,
|
||||||
|
item,
|
||||||
|
options,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
item: ShoppingItemView | null;
|
||||||
|
options: ItemOptions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
kind: (item?.kind ?? "type") as ShoppingItemKind,
|
||||||
|
productTypeId: item?.productTypeId ?? "",
|
||||||
|
productId: item?.productId ?? "",
|
||||||
|
text: item?.text ?? "",
|
||||||
|
quantity: item?.quantity != null ? quantityInput(item.quantity) : "",
|
||||||
|
unit: item?.kind === "text" ? item.unit : "",
|
||||||
|
note: item?.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 typeUnit = options.types.find((x) => x.id === v.productTypeId)?.unit ?? "";
|
||||||
|
const unitSuffix =
|
||||||
|
v.kind === "type" ? (item?.unit ?? typeUnit) : v.kind === "product" ? t.packageUnit : "";
|
||||||
|
const productGroups = new Map<string, ItemOptions["products"]>();
|
||||||
|
for (const p of options.products)
|
||||||
|
productGroups.set(p.typeName, [...(productGroups.get(p.typeName) ?? []), p]);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = item
|
||||||
|
? await updateShoppingItemAction(listId, item.id, {
|
||||||
|
quantity: v.quantity,
|
||||||
|
text: v.text,
|
||||||
|
unit: v.unit,
|
||||||
|
note: v.note,
|
||||||
|
})
|
||||||
|
: await addShoppingItemAction(listId, v);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(item ? t.toast.itemSaved : t.toast.itemAdded);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={item ? t.editItemTitle : t.addItemTitle}
|
||||||
|
description={item && item.kind !== "text" ? item.label : undefined}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="shopping-item-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="shopping-item-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
{item ? null : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{f.kind}</FieldLabel>
|
||||||
|
<Tabs value={v.kind} onValueChange={(x) => set("kind", x as ShoppingItemKind)}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{SHOPPING_ITEM_KINDS.map((k) => (
|
||||||
|
<TabsTrigger key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<FieldDescription>{t.kindHints[v.kind]}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!item && v.kind === "type" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||||
|
<FieldLabel htmlFor="si-type">{f.productType}</FieldLabel>
|
||||||
|
<Select value={v.productTypeId} onValueChange={(x) => set("productTypeId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="si-type"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productTypeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.productType} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{options.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>
|
||||||
|
) : null}
|
||||||
|
{!item && v.kind === "product" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)}>
|
||||||
|
<FieldLabel htmlFor="si-product">{f.product}</FieldLabel>
|
||||||
|
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="si-product"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{[...productGroups.entries()].map(([group, rows]) => (
|
||||||
|
<SelectGroup key={group}>
|
||||||
|
<SelectLabel>{group}</SelectLabel>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{v.kind === "text" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.text)}>
|
||||||
|
<FieldLabel htmlFor="si-text">{f.text}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-text"
|
||||||
|
value={v.text}
|
||||||
|
placeholder={f.textPlaceholder}
|
||||||
|
onChange={(e) => set("text", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.text)}
|
||||||
|
autoFocus={!item}
|
||||||
|
/>
|
||||||
|
{errors.text ? <FieldError>{errors.text}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.quantity)}>
|
||||||
|
<FieldLabel htmlFor="si-qty">
|
||||||
|
{v.kind === "product" ? f.packages : f.quantity}
|
||||||
|
{unitSuffix ? ` (${unitSuffix})` : ""}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.quantity}
|
||||||
|
onChange={(e) => set("quantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.quantity)}
|
||||||
|
/>
|
||||||
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
{v.kind === "text" ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="si-unit">{f.unit}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-unit"
|
||||||
|
value={v.unit}
|
||||||
|
placeholder={f.unitPlaceholder}
|
||||||
|
onChange={(e) => set("unit", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{v.kind !== "text" ? (
|
||||||
|
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="si-note">{f.itemNote}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-note"
|
||||||
|
value={v.note}
|
||||||
|
placeholder={f.itemNotePlaceholder}
|
||||||
|
onChange={(e) => set("note", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Check,
|
||||||
|
EllipsisVertical,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
ShoppingCart,
|
||||||
|
Sparkles,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-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 { Field, FieldLabel } from "@/components/ui/field";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import { formatStock } from "@/shared/stock";
|
||||||
|
import {
|
||||||
|
approveSuggestionsAction,
|
||||||
|
deleteShoppingListAction,
|
||||||
|
dismissSuggestionAction,
|
||||||
|
moveShoppingItemAction,
|
||||||
|
removeShoppingItemAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../service";
|
||||||
|
import { ShoppingItemDialog } from "./item-dialog";
|
||||||
|
import { ShoppingListDialog } from "./list-dialog";
|
||||||
|
import { PlanPanel } from "./plan-panel";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
function SuggestionsPanel({
|
||||||
|
listId,
|
||||||
|
suggestions,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
suggestions: SuggestionView[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const s = t.suggestions;
|
||||||
|
|
||||||
|
async function approve(ids: string[]) {
|
||||||
|
setBusy(ids.length > 1 ? "all" : ids[0]!);
|
||||||
|
const r = await approveSuggestionsAction(listId, ids);
|
||||||
|
setBusy(null);
|
||||||
|
if (r.ok) toast.success(t.toast.suggestionsAdded(r.data.added));
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
async function dismiss(id: string) {
|
||||||
|
setBusy(id);
|
||||||
|
const r = await dismissSuggestionAction(id);
|
||||||
|
setBusy(null);
|
||||||
|
if (r.ok) toast.success(t.toast.dismissed);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="sugg-h"
|
||||||
|
className="flex flex-col gap-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 id="sugg-h" className="flex items-center gap-1.5 font-medium">
|
||||||
|
<Sparkles className="size-4 text-amber-600" aria-hidden />
|
||||||
|
{s.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.description}</p>
|
||||||
|
</div>
|
||||||
|
{canWrite && suggestions.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => approve(suggestions.map((x) => x.productTypeId))}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{s.addAll}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y rounded-lg border bg-card">
|
||||||
|
{suggestions.map((x) => {
|
||||||
|
const reasons = [
|
||||||
|
x.suggestion.waste
|
||||||
|
? tr.stock.suggestions.wasteReduced(
|
||||||
|
x.suggestion.waste.purchases,
|
||||||
|
x.suggestion.waste.pct,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<li key={x.productTypeId} className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/${x.productTypeId}`} className="hover:underline">
|
||||||
|
{x.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{s.line(
|
||||||
|
formatStock(Math.max(0, x.current), x.unit),
|
||||||
|
formatStock(x.target, x.unit),
|
||||||
|
x.pct,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm tabular-nums">
|
||||||
|
{s.suggested(formatStock(x.suggestedQuantity, x.unit))}
|
||||||
|
{reasons.length ? (
|
||||||
|
<span className="text-xs text-muted-foreground"> · {reasons.join(" · ")}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy !== null}
|
||||||
|
title={s.dismissHint}
|
||||||
|
onClick={() => dismiss(x.productTypeId)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
{s.dismiss}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => approve([x.productTypeId])}
|
||||||
|
>
|
||||||
|
<Check />
|
||||||
|
{s.add}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICON = { type: Package, product: Tag, text: null } as const;
|
||||||
|
|
||||||
|
function ItemRow({
|
||||||
|
item,
|
||||||
|
canWrite,
|
||||||
|
onEdit,
|
||||||
|
onMove,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
item: ShoppingItemView;
|
||||||
|
canWrite: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onMove: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = KIND_ICON[item.kind];
|
||||||
|
return (
|
||||||
|
<li className={cn("flex items-center gap-3 p-3", item.checked && "bg-muted/30")}>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
||||||
|
>
|
||||||
|
{item.checked ? (
|
||||||
|
<Check className="size-4 text-income" />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">Aa</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"truncate font-medium",
|
||||||
|
item.checked && "text-muted-foreground line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.kind === "type" && item.productTypeId ? (
|
||||||
|
<Link href={`/stok/${item.productTypeId}`} className="hover:underline">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : item.kind === "product" && item.productId ? (
|
||||||
|
<Link href={`/stok/urunler/${item.productId}`} className="hover:underline">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
item.label
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{item.detail || item.stock ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{item.stock ? t.stockLine(item.stock.current, item.stock.target) : item.detail}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
{item.checked ? <Badge variant="secondary">{tr.shopping.mode.inCart}</Badge> : null}
|
||||||
|
{item.source === "suggestion" ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Sparkles className="size-3" aria-hidden />
|
||||||
|
{t.fromSuggestion}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{item.archived ? <Badge variant="secondary">{t.archivedRef}</Badge> : null}
|
||||||
|
{item.note ? <span className="text-xs text-muted-foreground">{item.note}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums">{item.quantityLabel}</span>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={`${item.label}: ${t.actions.more}`}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={onEdit}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onMove}>
|
||||||
|
<ArrowRightLeft />
|
||||||
|
{t.actions.moveTo}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={onRemove}>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.remove}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoveDialog({
|
||||||
|
listId,
|
||||||
|
item,
|
||||||
|
lists,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
item: ShoppingItemView;
|
||||||
|
lists: { id: string; name: string }[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState(lists[0]?.id ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await moveShoppingItemAction(listId, item.id, target);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.moved);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(r.error);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.moveTitle}
|
||||||
|
description={t.moveDescription(item.label)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !target}>
|
||||||
|
{t.actions.moveTo}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lists.length ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sm-target">{t.moveTarget}</FieldLabel>
|
||||||
|
<Select value={target} onValueChange={setTarget}>
|
||||||
|
<SelectTrigger id="sm-target" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{lists.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noOtherLists}</p>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id — kalemler, stok önerileri (onayla/reddet), akıllı mağaza önerisi. */
|
||||||
|
export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { list, items, canWrite } = data;
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ShoppingItemView | null>(null);
|
||||||
|
const [moving, setMoving] = useState<ShoppingItemView | null>(null);
|
||||||
|
const [editingList, setEditingList] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
|
||||||
|
async function remove(item: ShoppingItemView) {
|
||||||
|
const r = await removeShoppingItemAction(list.id, item.id);
|
||||||
|
if (r.ok) toast.success(t.toast.itemRemoved);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/alisveris">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.actions.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<PageHeader
|
||||||
|
title={list.name}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{t.itemCount(list.itemCount)}
|
||||||
|
{list.checkedCount ? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}` : ""}
|
||||||
|
{list.note ? ` · ${list.note}` : ""}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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 onSelect={() => setEditingList(true)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.editList}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setDeleting(true)}>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.deleteList}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.actions.addItem}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{items.length && list.status === "active" ? (
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/alisveris/${list.id}/mod`}>
|
||||||
|
<ShoppingCart />
|
||||||
|
{t.actions.goShopping}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{data.suggestions.length ? (
|
||||||
|
<SuggestionsPanel listId={list.id} suggestions={data.suggestions} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
{items.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{items.map((i) => (
|
||||||
|
<ItemRow
|
||||||
|
key={i.id}
|
||||||
|
item={i}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onEdit={() => setEditing(i)}
|
||||||
|
onMove={() => setMoving(i)}
|
||||||
|
onRemove={() => remove(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyList}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PlanPanel plan={data.plan} items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding || editing ? (
|
||||||
|
<ShoppingItemDialog
|
||||||
|
listId={list.id}
|
||||||
|
item={editing}
|
||||||
|
options={data.options}
|
||||||
|
onClose={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{moving ? (
|
||||||
|
<MoveDialog
|
||||||
|
listId={list.id}
|
||||||
|
item={moving}
|
||||||
|
lists={data.otherLists}
|
||||||
|
onClose={() => setMoving(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editingList ? (
|
||||||
|
<ShoppingListDialog list={list} onClose={() => setEditingList(false)} />
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.deleteDescription(list.name)}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
setDeleting(false);
|
||||||
|
const r = await deleteShoppingListAction(list.id);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.deleted);
|
||||||
|
router.push("/alisveris");
|
||||||
|
} else toast.error(r.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions.deleteList}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { saveShoppingListAction } from "../actions";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
/** Yeni liste / listeyi düzenle. Yeni liste açılınca detayına gider. */
|
||||||
|
export function ShoppingListDialog({
|
||||||
|
list,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
list: { id: string; name: string; note: string } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [v, setV] = useState({ name: list?.name ?? "", note: list?.note ?? "" });
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveShoppingListAction(list?.id ?? null, v);
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
if (!list && r.data && typeof r.data === "object" && "id" in r.data)
|
||||||
|
router.push(`/alisveris/${r.data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={list ? t.editTitle : t.createTitle}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="shopping-list-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="shopping-list-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.name)}>
|
||||||
|
<FieldLabel htmlFor="sl-name">{t.fields.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sl-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={t.fields.namePlaceholder}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sl-note">{t.fields.note}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sl-note"
|
||||||
|
value={v.note}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, ListChecks, Plus, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatDate } from "@/shared/format";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import type { ShoppingListSummary } from "../service";
|
||||||
|
import { ShoppingListDialog } from "./list-dialog";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
function ListCard({ list }: { list: ShoppingListSummary }) {
|
||||||
|
const done = list.status === "completed";
|
||||||
|
const Icon = done ? CheckCircle2 : ListChecks;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/alisveris/${list.id}`}
|
||||||
|
className="flex h-full flex-col gap-2 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<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">{list.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{t.itemCount(list.itemCount)}
|
||||||
|
{list.checkedCount && !done
|
||||||
|
? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{list.preview.length ? (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{list.preview.join(", ")}
|
||||||
|
{list.itemCount > list.preview.length ? "…" : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.emptyList}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-auto text-xs text-muted-foreground">
|
||||||
|
{done && list.completedAt
|
||||||
|
? t.completedAt(formatDate(list.completedAt))
|
||||||
|
: t.updated(formatDate(list.updatedAt))}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris: aktif ve tamamlanan listeler, yeni liste, bekleyen stok önerileri. */
|
||||||
|
export function ShoppingListsView({
|
||||||
|
active,
|
||||||
|
completed,
|
||||||
|
suggestions,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
active: ShoppingListSummary[];
|
||||||
|
completed: ShoppingListSummary[];
|
||||||
|
suggestions: number;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{suggestions > 0 ? (
|
||||||
|
<p className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm">
|
||||||
|
<Sparkles className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
|
||||||
|
{t.suggestionsBanner(suggestions)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.active}</h2>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" className="ml-auto" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.newList}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{active.length ? (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{active.map((l) => (
|
||||||
|
<ListCard key={l.id} list={l} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyActive}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{completed.length ? (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.completed}</h2>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{completed.map((l) => (
|
||||||
|
<ListCard key={l.id} list={l} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{creating ? <ShoppingListDialog list={null} onClose={() => setCreating(false)} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Globe, Info, Utensils } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice } from "@/shared/prices";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import type { PlanOption } from "@/shared/shopping-plan";
|
||||||
|
import type { ShoppingItemView, ShoppingPlanView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping.plan;
|
||||||
|
|
||||||
|
export type PlanChoice = "recommended" | "single";
|
||||||
|
|
||||||
|
const sameStores = (a: PlanOption | null, b: PlanOption | null) =>
|
||||||
|
(a?.stores.map((s) => s.storeId).join() ?? "") === (b?.stores.map((s) => s.storeId).join() ?? "");
|
||||||
|
|
||||||
|
/** "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)". */
|
||||||
|
export function planHeadline(plan: ShoppingPlanView): string | null {
|
||||||
|
const rec = plan.recommended;
|
||||||
|
if (!rec) return null;
|
||||||
|
const label = (id: string) => plan.stores[id]?.label ?? "—";
|
||||||
|
if (rec.stores.length === 1 && rec.missing.length === 0)
|
||||||
|
return t.singleBest(label(rec.stores[0]!.storeId), formatMoney(rec.totalMinor));
|
||||||
|
const parts = rec.stores.map((g) => t.part(label(g.storeId), g.lines.length)).join(", ");
|
||||||
|
const saving =
|
||||||
|
plan.savingMinor && plan.savingMinor > 0 ? ` (${t.saving(formatMoney(plan.savingMinor))})` : "";
|
||||||
|
return `${t.split(parts, formatMoney(rec.totalMinor))}${saving}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanGroups({
|
||||||
|
option,
|
||||||
|
plan,
|
||||||
|
itemLabel,
|
||||||
|
}: {
|
||||||
|
option: PlanOption;
|
||||||
|
plan: ShoppingPlanView;
|
||||||
|
itemLabel: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{option.stores.map((g) => {
|
||||||
|
const store = plan.stores[g.storeId];
|
||||||
|
return (
|
||||||
|
<section key={g.storeId} className="overflow-hidden rounded-lg border">
|
||||||
|
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b bg-muted/40 px-3 py-2">
|
||||||
|
<h3 className="flex min-w-0 items-center text-sm font-medium">
|
||||||
|
<StoreDot slot={store?.slot ?? 1} />
|
||||||
|
<span className="truncate">{store?.label ?? "—"}</span>
|
||||||
|
{store?.kind === "online" ? (
|
||||||
|
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
{g.mealCardMinor > 0 ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Utensils className="size-3" aria-hidden />
|
||||||
|
{t.mealCard(formatMoney(g.mealCardMinor))}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto text-sm font-medium tabular-nums">
|
||||||
|
{formatMoney(g.subtotalMinor + g.deliveryFeeMinor)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{g.lines.map((l) => {
|
||||||
|
const p = plan.products[l.productId];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={l.itemId}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{itemLabel.get(l.itemId) ?? "—"}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{p
|
||||||
|
? p.label === itemLabel.get(l.itemId)
|
||||||
|
? p.packageLabel
|
||||||
|
: `${p.label} · ${p.packageLabel}`
|
||||||
|
: "—"}
|
||||||
|
{p ? ` · ${formatUnitPrice(l.unitPriceMinor, p.typeUnit)}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">
|
||||||
|
<span className="block">{formatMoney(l.costMinor)}</span>
|
||||||
|
{l.packages > 1 ? (
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t.line(formatQuantity(l.packages * 1000), formatMoney(l.priceMinor))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{store?.kind === "online" ? (
|
||||||
|
<li className="flex justify-between px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{g.deliveryFeeMinor
|
||||||
|
? t.delivery(formatMoney(g.deliveryFeeMinor))
|
||||||
|
: t.freeDelivery}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{g.deliveryFeeMinor ? formatMoney(g.deliveryFeeMinor) : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{option.missing.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.missing}: {option.missing.map((id) => itemLabel.get(id) ?? "—").join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="flex justify-between border-t pt-2 text-sm font-medium">
|
||||||
|
<span>{t.total}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(option.totalMinor)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste detayında akıllı mağaza önerisi: özet cümle, önerilen / tek mağaza dağılımı. */
|
||||||
|
export function PlanPanel({ plan, items }: { plan: ShoppingPlanView; items: ShoppingItemView[] }) {
|
||||||
|
const [choice, setChoice] = useState<PlanChoice>("recommended");
|
||||||
|
const itemLabel = new Map(items.map((i) => [i.id, i.label]));
|
||||||
|
const headline = planHeadline(plan);
|
||||||
|
const differs = plan.single && !sameStores(plan.single, plan.recommended);
|
||||||
|
const option = choice === "single" && differs ? plan.single! : plan.recommended;
|
||||||
|
const hasText = items.some((i) => i.kind === "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4" aria-labelledby="plan-h">
|
||||||
|
<div>
|
||||||
|
<h2 id="plan-h" className="font-medium">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
{!option ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{headline ? <p className="text-sm font-medium">{headline}</p> : null}
|
||||||
|
{plan.single && plan.single.missing.length && differs ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.singleMissing(plan.single.missing.length)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{differs ? (
|
||||||
|
<Tabs value={choice} onValueChange={(x) => setChoice(x as PlanChoice)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="recommended">{t.recommended}</TabsTrigger>
|
||||||
|
<TabsTrigger value="single">{t.single}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
|
<PlanGroups option={option} plan={plan} itemLabel={itemLabel} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{plan.unpriced.length ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">{t.unpriced}:</span>{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{plan.unpriced.map((id) => itemLabel.get(id) ?? "—").join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t.unpricedHint}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.threshold(formatMoney(plan.thresholdMinor))}
|
||||||
|
</li>
|
||||||
|
{plan.mealCardBalanceMinor > 0 ? (
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Utensils className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.mealCardBalance(formatMoney(plan.mealCardBalanceMinor))}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{hasText ? (
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.textItems}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ShoppingCart, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { getActiveListsSummary } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const LIMIT = 3;
|
||||||
|
|
||||||
|
/** Dashboard: aktif alışveriş listeleri ve bekleyen stok önerileri. Liste ve öneri yoksa gizli. */
|
||||||
|
export async function ShoppingCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const data = await getActiveListsSummary(userId, householdId);
|
||||||
|
if (!data || (!data.lists.length && !data.suggestions)) return null;
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShoppingCart className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
{t.dashboard.title}
|
||||||
|
</CardTitle>
|
||||||
|
{data.suggestions ? (
|
||||||
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="size-3.5 text-amber-600" aria-hidden />
|
||||||
|
{t.dashboard.suggestions(data.suggestions)}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{data.lists.length ? (
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{data.lists.slice(0, LIMIT).map((l) => (
|
||||||
|
<li key={l.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||||
|
<Link href={`/alisveris/${l.id}`} className="min-w-0 truncate hover:underline">
|
||||||
|
{l.name}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{l.checkedCount
|
||||||
|
? t.checkedCount(l.checkedCount, l.itemCount)
|
||||||
|
: t.itemCount(l.itemCount)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.dashboard.empty}</p>
|
||||||
|
)}
|
||||||
|
<Link href="/alisveris" className="text-sm text-primary hover:underline">
|
||||||
|
{t.dashboard.open}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Check, Globe } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { startTransition, useOptimistic } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import type { PlanLine } from "@/shared/shopping-plan";
|
||||||
|
import { setShoppingItemCheckedAction } from "../actions";
|
||||||
|
import type { ShoppingItemView, ShoppingListDetail } from "../service";
|
||||||
|
import { planHeadline, type PlanChoice } from "./plan-panel";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const m = t.mode;
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
slot: number | null;
|
||||||
|
online: boolean;
|
||||||
|
hint?: string;
|
||||||
|
rows: { item: ShoppingItemView; line: PlanLine | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
item,
|
||||||
|
line,
|
||||||
|
product,
|
||||||
|
canWrite,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: ShoppingItemView;
|
||||||
|
line: PlanLine | null;
|
||||||
|
product: { label: string; packageLabel: string } | undefined;
|
||||||
|
canWrite: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const sub = [
|
||||||
|
line
|
||||||
|
? line.packages > 1
|
||||||
|
? t.plan.line(formatQuantity(line.packages * 1000), formatMoney(line.priceMinor))
|
||||||
|
: formatMoney(line.priceMinor)
|
||||||
|
: null,
|
||||||
|
line && product && item.kind === "type" ? `${product.label} · ${product.packageLabel}` : null,
|
||||||
|
item.note || null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={item.checked}
|
||||||
|
aria-label={m.check(item.label)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none disabled:cursor-default",
|
||||||
|
item.checked && "bg-muted/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-md border-2",
|
||||||
|
item.checked ? "border-income bg-income text-white" : "border-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.checked ? <Check className="size-4.5" strokeWidth={3} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block text-base font-medium",
|
||||||
|
item.checked && "text-muted-foreground line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{sub.length ? (
|
||||||
|
<span className="line-clamp-2 text-sm text-muted-foreground">{sub.join(" · ")}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-base tabular-nums">{item.quantityLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/mod — mağazaya göre gruplanmış, büyük dokunma alanlı kontrol listesi. */
|
||||||
|
export function ShoppingModeView({
|
||||||
|
data,
|
||||||
|
choice,
|
||||||
|
}: {
|
||||||
|
data: ShoppingListDetail;
|
||||||
|
choice: PlanChoice;
|
||||||
|
}) {
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
const { list, plan, canWrite } = data;
|
||||||
|
const [items, setOptimistic] = useOptimistic(
|
||||||
|
data.items,
|
||||||
|
(state, change: { id: string; checked: boolean }) =>
|
||||||
|
state.map((i) => (i.id === change.id ? { ...i, checked: change.checked } : i)),
|
||||||
|
);
|
||||||
|
const option = (choice === "single" ? plan.single : null) ?? plan.recommended;
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
const groups: Group[] = [];
|
||||||
|
const placed = new Set<string>();
|
||||||
|
for (const g of option?.stores ?? []) {
|
||||||
|
const store = plan.stores[g.storeId];
|
||||||
|
const rows = g.lines.flatMap((line) => {
|
||||||
|
const item = byId.get(line.itemId);
|
||||||
|
if (!item) return [];
|
||||||
|
placed.add(item.id);
|
||||||
|
return [{ item, line }];
|
||||||
|
});
|
||||||
|
groups.push({
|
||||||
|
key: g.storeId,
|
||||||
|
title: store?.label ?? "—",
|
||||||
|
slot: store?.slot ?? null,
|
||||||
|
online: store?.kind === "online",
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rest = items.filter((i) => !placed.has(i.id));
|
||||||
|
if (rest.length)
|
||||||
|
groups.push({
|
||||||
|
key: "other",
|
||||||
|
title: m.other,
|
||||||
|
slot: null,
|
||||||
|
online: false,
|
||||||
|
hint: m.otherHint,
|
||||||
|
rows: rest.map((item) => ({ item, line: null })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const done = items.filter((i) => i.checked).length;
|
||||||
|
const pct = items.length ? Math.round((done / items.length) * 100) : 0;
|
||||||
|
|
||||||
|
function toggle(item: ShoppingItemView) {
|
||||||
|
const checked = !item.checked;
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimistic({ id: item.id, checked });
|
||||||
|
const r = await setShoppingItemCheckedAction(list.id, item.id, checked);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headline = planHeadline(plan);
|
||||||
|
const differs =
|
||||||
|
plan.single &&
|
||||||
|
plan.single.stores.map((s) => s.storeId).join() !==
|
||||||
|
plan.recommended?.stores.map((s) => s.storeId).join();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href={`/alisveris/${list.id}`}>
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.actions.backToList}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{m.title}: {list.name}
|
||||||
|
</h1>
|
||||||
|
{headline ? <p className="text-sm text-muted-foreground">{headline}</p> : null}
|
||||||
|
{differs ? (
|
||||||
|
<nav aria-label={m.planChoice} className="flex gap-2">
|
||||||
|
{(["recommended", "single"] as const).map((c) => (
|
||||||
|
<Button
|
||||||
|
key={c}
|
||||||
|
size="sm"
|
||||||
|
variant={c === choice ? "secondary" : "ghost"}
|
||||||
|
asChild
|
||||||
|
aria-current={c === choice ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Link href={c === "single" ? "?plan=single" : "?"} replace scroll={false}>
|
||||||
|
{c === "single" ? t.plan.single : t.plan.recommended}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-14 z-10 -mx-4 bg-background/95 px-4 py-2 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium tabular-nums">{m.progress(done, items.length)}</span>
|
||||||
|
{done === items.length && items.length ? (
|
||||||
|
<span className="text-income">{m.allDone}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={items.length}
|
||||||
|
aria-valuenow={done}
|
||||||
|
aria-label={m.progress(done, items.length)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-income transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((g) => {
|
||||||
|
const groupDone = g.rows.filter((r) => r.item.checked).length;
|
||||||
|
return (
|
||||||
|
<section key={g.key} className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<header className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
|
||||||
|
<h2 className="flex min-w-0 items-center font-medium">
|
||||||
|
{g.slot ? <StoreDot slot={g.slot} /> : null}
|
||||||
|
<span className="truncate">{g.title}</span>
|
||||||
|
{g.online ? (
|
||||||
|
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</h2>
|
||||||
|
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
|
||||||
|
{groupDone}/{g.rows.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{g.hint ? <p className="px-4 pt-2 text-xs text-muted-foreground">{g.hint}</p> : null}
|
||||||
|
<ul className="divide-y">
|
||||||
|
{g.rows.map(({ item, line }) => (
|
||||||
|
<Row
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
line={line}
|
||||||
|
product={line ? plan.products[line.productId] : undefined}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onToggle={() => toggle(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
getShoppingList,
|
||||||
|
listShoppingLists,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
|
||||||
|
const typeId = (seedRef: string) =>
|
||||||
|
ProductTypeModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const productId = (seedRef: string) =>
|
||||||
|
ProductModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("liste okuma ve akıllı öneri", () => {
|
||||||
|
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||||||
|
const data = (await listShoppingLists(ayse, cakir))!;
|
||||||
|
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||||||
|
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(data.canWrite).toBe(true);
|
||||||
|
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||||||
|
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items).toHaveLength(9);
|
||||||
|
const milk = d.items.find((i) => i.label === "Süt")!;
|
||||||
|
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||||||
|
expect(milk.stock).not.toBeNull();
|
||||||
|
const yumos = d.items.find((i) => i.kind === "product")!;
|
||||||
|
expect(yumos.quantityLabel).toBe("1 paket");
|
||||||
|
const candle = d.items.find((i) => i.kind === "text")!;
|
||||||
|
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||||||
|
|
||||||
|
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||||||
|
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||||||
|
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||||||
|
|
||||||
|
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||||||
|
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||||||
|
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||||||
|
const rec = d.plan.recommended!;
|
||||||
|
expect(rec.missing).toEqual([]);
|
||||||
|
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||||||
|
expect(planned).toHaveLength(7);
|
||||||
|
expect(planned).not.toContain(candle.id);
|
||||||
|
for (const g of rec.stores) {
|
||||||
|
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||||||
|
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||||||
|
}
|
||||||
|
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||||||
|
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||||||
|
expect(yumosLine.productId).toBe(yumos.productId);
|
||||||
|
expect(d.plan.single).not.toBeNull();
|
||||||
|
expect(d.plan.thresholdMinor).toBe(10_000);
|
||||||
|
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||||||
|
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||||||
|
expect(d.options.types.length).toBeGreaterThan(10);
|
||||||
|
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kalem işlemleri", () => {
|
||||||
|
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||||||
|
const milk = await typeId("sut");
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||||||
|
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||||||
|
|
||||||
|
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
const id = added.ok ? added.data.id : "";
|
||||||
|
const brand = await addShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||||||
|
);
|
||||||
|
expect(brand.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
id,
|
||||||
|
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||||||
|
label: "AA pil",
|
||||||
|
quantityLabel: "6 adet",
|
||||||
|
});
|
||||||
|
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||||||
|
expect(ariel.quantityLabel).toBe("2 paket");
|
||||||
|
|
||||||
|
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||||||
|
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
// Sepete alınanlar sona.
|
||||||
|
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||||||
|
expect(d.list.checkedCount).toBe(1);
|
||||||
|
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||||||
|
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||||||
|
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eşik önerileri", () => {
|
||||||
|
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||||||
|
const lentil = await typeId("kirmizi-mercimek");
|
||||||
|
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||||||
|
expect(row.source).toBe("suggestion");
|
||||||
|
expect(row.quantity).toBeGreaterThan(0);
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||||||
|
// Başka aktif listede de öneri olarak görünmez.
|
||||||
|
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||||||
|
const tablet = await typeId("tablet-deterjan");
|
||||||
|
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||||||
|
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||||||
|
await ProductTypeModel.updateOne(
|
||||||
|
{ seedRef: "tablet-deterjan" },
|
||||||
|
{ $set: { suggestionDismissedAt: null } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liste yönetimi", () => {
|
||||||
|
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||||||
|
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||||||
|
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||||||
|
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||||||
|
expect(
|
||||||
|
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { loadTypeOffers } from "@/features/prices/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { getStock, type ProductTypeView } from "@/features/stock/service";
|
||||||
|
import { storeLabel } from "@/features/stores/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 { HouseholdModel } from "@/models/household";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { seriesSlot } from "@/shared/prices";
|
||||||
|
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type {
|
||||||
|
ShoppingItemForm,
|
||||||
|
ShoppingItemUpdate,
|
||||||
|
ShoppingListForm,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import {
|
||||||
|
requiredQuantity,
|
||||||
|
suggestionHidden,
|
||||||
|
type ShoppingItemKind,
|
||||||
|
type ShoppingItemSource,
|
||||||
|
type ShoppingListStatus,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||||
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş listeleri (Bölüm 6.12). Hane içinde ortak; okuma viewer, yazma member.
|
||||||
|
* Stok eşiğinin altındaki ürün tipleri aktif listelere onayla/reddet önerisi olarak gelir.
|
||||||
|
* Akıllı mağaza önerisi saf motorla (shared/shopping-plan) okuma sırasında hesaplanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
type ItemLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: Types.ObjectId | null;
|
||||||
|
productId: Types.ObjectId | null;
|
||||||
|
text: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
checkedAt: Date | null;
|
||||||
|
addedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
items: ItemLean[];
|
||||||
|
completedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
itemCount: number;
|
||||||
|
checkedCount: number;
|
||||||
|
/** İlk birkaç kalemin adı (kart önizlemesi). */
|
||||||
|
preview: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingItemView = {
|
||||||
|
id: string;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: string | null;
|
||||||
|
productId: string | null;
|
||||||
|
text: string;
|
||||||
|
label: string;
|
||||||
|
/** Ürün: paket ve ürün tipi; ürün tipi: kategori yolu. */
|
||||||
|
detail: string;
|
||||||
|
quantity: number | null;
|
||||||
|
/** Ürün tipinde tipin birimi, üründe "paket", serbest metinde girilen birim. */
|
||||||
|
unit: string;
|
||||||
|
/** "2 kg", "3 paket"; boş miktarda ürün tipinde "" (en az 1 paket). */
|
||||||
|
quantityLabel: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
/** Ürün tipi/ürün arşivlendi. */
|
||||||
|
archived: boolean;
|
||||||
|
/** Ürün tipi kaleminde evdeki stok. */
|
||||||
|
stock: { current: string; target: string; low: boolean } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestionView = {
|
||||||
|
productTypeId: string;
|
||||||
|
name: string;
|
||||||
|
categoryPath: string;
|
||||||
|
unit: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
pct: number | null;
|
||||||
|
suggestedQuantity: number;
|
||||||
|
suggestion: PurchaseSuggestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanStoreRef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
slot: number;
|
||||||
|
mealCard: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingPlanView = PlanResult & {
|
||||||
|
stores: Record<string, PlanStoreRef>;
|
||||||
|
products: Record<string, { label: string; packageLabel: string; typeUnit: string }>;
|
||||||
|
thresholdMinor: number;
|
||||||
|
/** Bu kullanıcının görebildiği yemek kartı bakiyelerinin toplamı. */
|
||||||
|
mealCardBalanceMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemOptions = {
|
||||||
|
types: { id: string; name: string; unit: string; path: string }[];
|
||||||
|
products: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
productTypeId: string;
|
||||||
|
typeName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListDetail = {
|
||||||
|
list: ShoppingListSummary;
|
||||||
|
items: ShoppingItemView[];
|
||||||
|
suggestions: SuggestionView[];
|
||||||
|
plan: ShoppingPlanView;
|
||||||
|
options: ItemOptions;
|
||||||
|
/** Kalem taşımak için diğer aktif listeler. */
|
||||||
|
otherLists: { id: string; name: string }[];
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Yardımcılar ---
|
||||||
|
|
||||||
|
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||||
|
|
||||||
|
function summary(l: ListLean, labels: Map<string, string>): ShoppingListSummary {
|
||||||
|
return {
|
||||||
|
id: String(l._id),
|
||||||
|
name: l.name,
|
||||||
|
status: l.status,
|
||||||
|
note: l.note,
|
||||||
|
itemCount: l.items.length,
|
||||||
|
checkedCount: l.items.filter((i) => i.checked).length,
|
||||||
|
preview: l.items.slice(0, 4).map((i) => itemName(i, labels)),
|
||||||
|
updatedAt: l.updatedAt.toISOString(),
|
||||||
|
completedAt: iso(l.completedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refKey = (i: Pick<ItemLean, "kind" | "productTypeId" | "productId">) =>
|
||||||
|
i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null;
|
||||||
|
|
||||||
|
function itemName(i: ItemLean, labels: Map<string, string>) {
|
||||||
|
const key = refKey(i);
|
||||||
|
return key ? (labels.get(key) ?? "—") : i.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemlerin ürün tipi ve ürün adları (arşivliler dahil). */
|
||||||
|
async function loadLabels(householdId: Types.ObjectId, lists: ListLean[]) {
|
||||||
|
const typeIds = new Set<string>();
|
||||||
|
const productIds = new Set<string>();
|
||||||
|
for (const l of lists)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.productTypeId) typeIds.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.add(String(i.productId));
|
||||||
|
}
|
||||||
|
const [types, products] = await Promise.all([
|
||||||
|
ProductTypeModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...typeIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...productIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ brand: 1, name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; brand: string; name: string }[]>(),
|
||||||
|
]);
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const x of types) labels.set(`t:${x._id}`, x.name);
|
||||||
|
for (const x of products) labels.set(`p:${x._id}`, x.name ? `${x.brand} ${x.name}` : x.brand);
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(m: Membership, listId: string) {
|
||||||
|
const id = toObjectId(listId);
|
||||||
|
if (!id) return null;
|
||||||
|
return ShoppingListModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<ListLean>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi başına son alım hareketinin giriş zamanı (öneri reddi için). */
|
||||||
|
async function lastPurchaseAt(householdId: Types.ObjectId, typeIds: Types.ObjectId[]) {
|
||||||
|
if (!typeIds.length) return new Map<string, Date>();
|
||||||
|
const rows = await StockMovementModel.aggregate<{ _id: Types.ObjectId; at: Date }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: typeIds },
|
||||||
|
kind: "purchase",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $group: { _id: "$productTypeId", at: { $max: "$createdAt" } } },
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [String(r._id), r.at]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bekleyen eşik önerileri: eşiğin altındaki ürün tipleri; hiçbir aktif listede (alınmamış kalem
|
||||||
|
* olarak, tip ya da o tipin ürünü) yoksa ve reddedilmemişse.
|
||||||
|
*/
|
||||||
|
async function pendingSuggestions(
|
||||||
|
m: Membership,
|
||||||
|
low: ProductTypeView[],
|
||||||
|
): Promise<SuggestionView[]> {
|
||||||
|
if (!low.length) return [];
|
||||||
|
const lowIds = low.map((x) => new Types.ObjectId(x.id));
|
||||||
|
const [active, dismissed, purchases] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null, status: "active" })
|
||||||
|
.select({ items: 1 })
|
||||||
|
.lean<{ items: ItemLean[] }[]>(),
|
||||||
|
ProductTypeModel.find({ householdId: m.householdId, _id: { $in: lowIds } })
|
||||||
|
.select({ suggestionDismissedAt: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; suggestionDismissedAt: Date | null }[]>(),
|
||||||
|
lastPurchaseAt(m.householdId, lowIds),
|
||||||
|
]);
|
||||||
|
const listed = new Set<string>();
|
||||||
|
const productIds: Types.ObjectId[] = [];
|
||||||
|
for (const l of active)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.checked) continue;
|
||||||
|
if (i.productTypeId) listed.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.push(i.productId);
|
||||||
|
}
|
||||||
|
if (productIds.length) {
|
||||||
|
const rows = await ProductModel.find({ householdId: m.householdId, _id: { $in: productIds } })
|
||||||
|
.select({ productTypeId: 1 })
|
||||||
|
.lean<{ productTypeId: Types.ObjectId }[]>();
|
||||||
|
for (const r of rows) listed.add(String(r.productTypeId));
|
||||||
|
}
|
||||||
|
const dismissedAt = new Map(dismissed.map((d) => [String(d._id), d.suggestionDismissedAt]));
|
||||||
|
return low
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
!listed.has(x.id) &&
|
||||||
|
!suggestionHidden(dismissedAt.get(x.id) ?? null, purchases.get(x.id) ?? null),
|
||||||
|
)
|
||||||
|
.map((x) => ({
|
||||||
|
productTypeId: x.id,
|
||||||
|
name: x.name,
|
||||||
|
categoryPath: x.categoryPath,
|
||||||
|
unit: x.unit,
|
||||||
|
current: x.current,
|
||||||
|
target: x.targetQuantity,
|
||||||
|
pct: x.level.pct,
|
||||||
|
suggestedQuantity: x.suggestedQuantity,
|
||||||
|
suggestion: x.suggestion,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eşik önerileri (dashboard ve stok sayfası özetleri için). */
|
||||||
|
export async function getPendingSuggestions(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<SuggestionView[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const stock = await getStock(userId, householdId);
|
||||||
|
return stock ? pendingSuggestions(m, stock.suggestions) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Okuma ---
|
||||||
|
|
||||||
|
export async function listShoppingLists(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{
|
||||||
|
active: ShoppingListSummary[];
|
||||||
|
completed: ShoppingListSummary[];
|
||||||
|
suggestions: number;
|
||||||
|
canWrite: boolean;
|
||||||
|
} | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const [lists, suggestions] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<ListLean[]>(),
|
||||||
|
getPendingSuggestions(userId, householdId),
|
||||||
|
]);
|
||||||
|
const labels = await loadLabels(m.householdId, lists);
|
||||||
|
return {
|
||||||
|
active: lists.filter((l) => l.status === "active").map((l) => summary(l, labels)),
|
||||||
|
completed: lists
|
||||||
|
.filter((l) => l.status === "completed")
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((l) => summary(l, labels)),
|
||||||
|
suggestions: suggestions?.length ?? 0,
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: aktif listelerin özeti. */
|
||||||
|
export async function getActiveListsSummary(userId: string, householdId: string) {
|
||||||
|
const data = await listShoppingLists(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return { lists: data.active, suggestions: data.suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemView(
|
||||||
|
i: ItemLean,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
archivedTypes: Map<string, { name: string; unit: string }>,
|
||||||
|
): ShoppingItemView {
|
||||||
|
const base = {
|
||||||
|
id: String(i._id),
|
||||||
|
kind: i.kind,
|
||||||
|
productTypeId: i.productTypeId ? String(i.productTypeId) : null,
|
||||||
|
productId: i.productId ? String(i.productId) : null,
|
||||||
|
text: i.text,
|
||||||
|
quantity: i.quantity,
|
||||||
|
note: i.note,
|
||||||
|
source: i.source,
|
||||||
|
checked: i.checked,
|
||||||
|
};
|
||||||
|
if (i.kind === "type") {
|
||||||
|
const ty = types.get(String(i.productTypeId));
|
||||||
|
const fallback = archivedTypes.get(String(i.productTypeId));
|
||||||
|
const unit = ty?.unit ?? fallback?.unit ?? "";
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: ty?.name ?? fallback?.name ?? "—",
|
||||||
|
detail: ty?.categoryPath ?? "",
|
||||||
|
unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatStock(i.quantity, unit),
|
||||||
|
archived: !ty,
|
||||||
|
stock: ty
|
||||||
|
? {
|
||||||
|
current: formatStock(Math.max(0, ty.current), ty.unit),
|
||||||
|
target: formatStock(ty.targetQuantity, ty.unit),
|
||||||
|
low: ty.level.low,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (i.kind === "product") {
|
||||||
|
const p = products.get(String(i.productId));
|
||||||
|
const packages = i.quantity ?? QUANTITY_SCALE;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: p?.label ?? "—",
|
||||||
|
detail: p ? `${p.packageLabel} · ${p.productTypeName}` : "",
|
||||||
|
unit: t.packageUnit,
|
||||||
|
quantityLabel: `${formatQuantity(packages)} ${t.packageUnit}`,
|
||||||
|
archived: !p || p.archived,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: i.text,
|
||||||
|
detail: "",
|
||||||
|
unit: i.unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatQuantity(i.quantity, i.unit || undefined),
|
||||||
|
archived: false,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPlan(
|
||||||
|
userId: string,
|
||||||
|
m: Membership,
|
||||||
|
items: ItemLean[],
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
): Promise<ShoppingPlanView> {
|
||||||
|
const planItems = items
|
||||||
|
.filter((i) => i.kind !== "text")
|
||||||
|
.map((i) => {
|
||||||
|
const p = i.productId ? products.get(String(i.productId)) : undefined;
|
||||||
|
const typeId = i.kind === "product" ? (p?.productTypeId ?? "") : String(i.productTypeId);
|
||||||
|
return {
|
||||||
|
id: String(i._id),
|
||||||
|
productTypeId: typeId,
|
||||||
|
quantity: requiredQuantity(i, p?.inTypeUnit ?? null),
|
||||||
|
productId: i.kind === "product" ? String(i.productId) : null,
|
||||||
|
bulky: types.get(typeId)?.bulky ?? false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((i) => i.productTypeId);
|
||||||
|
const typeIds = [...new Set(planItems.map((i) => i.productTypeId))].map(
|
||||||
|
(x) => new Types.ObjectId(x),
|
||||||
|
);
|
||||||
|
const [offers, stores, household, accounts] = await Promise.all([
|
||||||
|
loadTypeOffers(m.householdId, typeIds, { activeOnly: true }),
|
||||||
|
StoreModel.find({ householdId: m.householdId }).sort({ createdAt: 1, _id: 1 }).lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
mealCardAccepted: boolean;
|
||||||
|
mealCardProviders: string[];
|
||||||
|
archivedAt: Date | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
|
||||||
|
listAccounts(userId, String(m.householdId)),
|
||||||
|
]);
|
||||||
|
const mealCards = (accounts?.accounts ?? [])
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" &&
|
||||||
|
!a.archived &&
|
||||||
|
a.mealCardProvider &&
|
||||||
|
a.balanceMinor !== null &&
|
||||||
|
a.balanceMinor > 0,
|
||||||
|
)
|
||||||
|
.map((a) => ({ provider: a.mealCardProvider!, balanceMinor: a.balanceMinor! }));
|
||||||
|
const active = stores.filter((s) => !s.archivedAt);
|
||||||
|
const threshold = household?.settings?.extraStoreSavingThresholdMinor ?? 10_000;
|
||||||
|
const result = planShopping({
|
||||||
|
items: planItems,
|
||||||
|
offers,
|
||||||
|
stores: active.map((s) => ({
|
||||||
|
id: String(s._id),
|
||||||
|
kind: s.kind,
|
||||||
|
distanceM: s.distanceM,
|
||||||
|
carryDifficulty: s.carryDifficulty,
|
||||||
|
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||||
|
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||||
|
mealCardProviders: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||||
|
})),
|
||||||
|
mealCards,
|
||||||
|
extraStoreThresholdMinor: threshold,
|
||||||
|
});
|
||||||
|
// Planda geçen ürünlerin etiketleri (listede olmayan muadil markalar dahil).
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const o of [result.single, result.recommended])
|
||||||
|
for (const g of o?.stores ?? []) for (const l of g.lines) used.add(l.productId);
|
||||||
|
for (const l of Object.values(result.cheapest)) used.add(l.productId);
|
||||||
|
const missing = [...used].filter((id) => !products.has(id));
|
||||||
|
if (missing.length) {
|
||||||
|
const extra = await listProducts(userId, String(m.householdId), { includeArchived: true });
|
||||||
|
for (const p of extra?.products ?? []) products.set(p.id, p);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
stores: Object.fromEntries(
|
||||||
|
stores.map((s, i) => [
|
||||||
|
String(s._id),
|
||||||
|
{
|
||||||
|
id: String(s._id),
|
||||||
|
label: storeLabel(s),
|
||||||
|
kind: s.kind,
|
||||||
|
slot: seriesSlot(i),
|
||||||
|
mealCard: s.mealCardAccepted,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
products: Object.fromEntries(
|
||||||
|
[...used].flatMap((id) => {
|
||||||
|
const p = products.get(id);
|
||||||
|
return p
|
||||||
|
? [[id, { label: p.label, packageLabel: p.packageLabel, typeUnit: p.typeUnit }]]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
thresholdMinor: threshold,
|
||||||
|
mealCardBalanceMinor: mealCards.reduce((a, c) => a + c.balanceMinor, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShoppingList(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ShoppingListDetail | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return null;
|
||||||
|
const [stock, productList, others, labels] = await Promise.all([
|
||||||
|
getStock(userId, householdId),
|
||||||
|
listProducts(userId, householdId, { includeArchived: true }),
|
||||||
|
ShoppingListModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "active",
|
||||||
|
_id: { $ne: list._id },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
loadLabels(m.householdId, [list]),
|
||||||
|
]);
|
||||||
|
if (!stock || !productList) return null;
|
||||||
|
const types = new Map(stock.items.map((x) => [x.id, x]));
|
||||||
|
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||||
|
// Arşivlenmiş ürün tiplerinin adı/birimi (getStock yalnızca aktifleri döner).
|
||||||
|
const archivedTypeIds = list.items
|
||||||
|
.filter((i) => i.productTypeId && !types.has(String(i.productTypeId)))
|
||||||
|
.map((i) => i.productTypeId!);
|
||||||
|
const archivedTypes = new Map(
|
||||||
|
(
|
||||||
|
await ProductTypeModel.find({ householdId: m.householdId, _id: { $in: archivedTypeIds } })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>()
|
||||||
|
).map((x) => [String(x._id), { name: x.name, unit: x.unit }]),
|
||||||
|
);
|
||||||
|
const items = list.items
|
||||||
|
.map((i) => itemView(i, types, products, archivedTypes))
|
||||||
|
.sort((a, b) => Number(a.checked) - Number(b.checked));
|
||||||
|
const isActive = list.status === "active";
|
||||||
|
const canWrite = hasHouseholdRole(m.role, "member") && isActive;
|
||||||
|
const [suggestions, plan] = await Promise.all([
|
||||||
|
isActive ? pendingSuggestions(m, stock.suggestions) : Promise.resolve([]),
|
||||||
|
buildPlan(userId, m, list.items, products, types),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
list: summary(list, labels),
|
||||||
|
items,
|
||||||
|
suggestions,
|
||||||
|
plan,
|
||||||
|
options: {
|
||||||
|
types: stock.items
|
||||||
|
.map((x) => ({ id: x.id, name: x.name, unit: x.unit, path: x.categoryPath }))
|
||||||
|
.sort((a, b) => compareTr(a.name, b.name)),
|
||||||
|
products: productList.products
|
||||||
|
.filter((p) => !p.archived && !p.conversionMissing && types.has(p.productTypeId))
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
productTypeId: p.productTypeId,
|
||||||
|
typeName: p.productTypeName,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||||
|
canWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: listeler ---
|
||||||
|
|
||||||
|
async function writableList(actorId: string, householdId: string, listId: string) {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return { error: fail(tr.common.notFound) } as const;
|
||||||
|
if (list.status !== "active") return { error: fail(t.validation.notActive) } as const;
|
||||||
|
return { error: null, m, list, actor: new Types.ObjectId(actorId) } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await ShoppingListModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $set: { name: input.name, note: input.note, updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste silme (soft delete, audit). Tamamlanmış liste de silinebilir. */
|
||||||
|
export async function deleteShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: list._id },
|
||||||
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: "shoppingList.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "shoppingList",
|
||||||
|
targetId: listId,
|
||||||
|
diff: { name: list.name, status: list.status, items: list.items.length },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: kalemler ---
|
||||||
|
|
||||||
|
/** Ürün tipi / ürün aktif mi; aynı kalem listede alınmamış olarak var mı? */
|
||||||
|
async function checkItemRef(
|
||||||
|
m: Membership,
|
||||||
|
list: ListLean,
|
||||||
|
input: Pick<ShoppingItemForm, "kind" | "productTypeId" | "productId">,
|
||||||
|
): Promise<ActionResult<never> | null> {
|
||||||
|
if (input.kind === "type") {
|
||||||
|
const ok = await ProductTypeModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productTypeId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.productType, { productTypeId: t.validation.productType });
|
||||||
|
}
|
||||||
|
if (input.kind === "product") {
|
||||||
|
const ok = await ProductModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.product, { productId: t.validation.product });
|
||||||
|
}
|
||||||
|
const key = refKey({
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
});
|
||||||
|
if (key && list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicate);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingItemForm,
|
||||||
|
source: ShoppingItemSource = "manual",
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const bad = await checkItemRef(r.m, r.list, input);
|
||||||
|
if (bad) return bad;
|
||||||
|
const _id = new Types.ObjectId();
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{
|
||||||
|
$push: {
|
||||||
|
items: {
|
||||||
|
_id,
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
text: input.text,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unit: input.unit,
|
||||||
|
note: input.note,
|
||||||
|
source,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok({ id: String(_id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
input: ShoppingItemUpdate,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
if (item.kind === "text" && !input.text)
|
||||||
|
return fail(t.validation.text, { text: t.validation.text });
|
||||||
|
const set: Record<string, unknown> = {
|
||||||
|
"items.$.quantity": input.quantity,
|
||||||
|
"items.$.note": input.note,
|
||||||
|
updatedBy: r.actor,
|
||||||
|
};
|
||||||
|
if (item.kind === "text") {
|
||||||
|
set["items.$.text"] = input.text;
|
||||||
|
set["items.$.unit"] = input.unit;
|
||||||
|
}
|
||||||
|
await ShoppingListModel.updateOne({ _id: r.list._id, "items._id": item._id }, { $set: set });
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id || !r.list.items.some((i) => i._id.equals(id))) return fail(tr.common.notFound);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alışveriş modunda sepete alındı / geri alındı. */
|
||||||
|
export async function setShoppingItemChecked(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const res = await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id, "items._id": id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"items.$.checked": checked,
|
||||||
|
"items.$.checkedBy": checked ? r.actor : null,
|
||||||
|
"items.$.checkedAt": checked ? new Date() : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemi başka bir aktif listeye taşır. */
|
||||||
|
export async function moveShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
targetListId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const target = await writableList(actorId, householdId, targetListId);
|
||||||
|
if (target.error) return target.error;
|
||||||
|
if (target.list._id.equals(r.list._id)) return fail(t.validation.sameList);
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
const key = refKey(item);
|
||||||
|
if (key && target.list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicateIn(target.list.name));
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: target.list._id },
|
||||||
|
{
|
||||||
|
$push: { items: { ...item, checked: false, checkedBy: null, checkedAt: null } },
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: item._id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eşik önerileri ---
|
||||||
|
|
||||||
|
/** Önerileri onayla: önerilen miktarla ürün tipi kalemi olarak eklenir. */
|
||||||
|
export async function approveSuggestions(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
productTypeIds: string[],
|
||||||
|
): Promise<ActionResult<{ added: number }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const stock = await getStock(actorId, householdId);
|
||||||
|
if (!stock) return fail(tr.common.unauthorized);
|
||||||
|
const pending = new Map(
|
||||||
|
(await pendingSuggestions(r.m, stock.suggestions)).map((x) => [x.productTypeId, x]),
|
||||||
|
);
|
||||||
|
const items = productTypeIds
|
||||||
|
.map((id) => pending.get(id))
|
||||||
|
.filter((x): x is SuggestionView => Boolean(x))
|
||||||
|
.map((x) => ({
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
kind: "type" as const,
|
||||||
|
productTypeId: new Types.ObjectId(x.productTypeId),
|
||||||
|
productId: null,
|
||||||
|
text: "",
|
||||||
|
quantity: x.suggestedQuantity > 0 ? x.suggestedQuantity : null,
|
||||||
|
unit: "",
|
||||||
|
note: "",
|
||||||
|
source: "suggestion" as const,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
}));
|
||||||
|
if (!items.length) return fail(t.validation.suggestionGone);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $push: { items: { $each: items } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok({ added: items.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öneriyi reddet: stok yenilenene (yeni alım) kadar gösterilmez. */
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productTypeId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productTypeId);
|
||||||
|
const res = id
|
||||||
|
? await ProductTypeModel.updateOne(
|
||||||
|
{ _id: id, householdId: m.householdId, archivedAt: null },
|
||||||
|
{ $set: { suggestionDismissedAt: new Date() } },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return res?.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +74,7 @@ export function ProductTypeFormDialog({
|
|||||||
step: item ? quantityInput(item.step) : "1",
|
step: item ? quantityInput(item.step) : "1",
|
||||||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||||||
note: item?.note ?? "",
|
note: item?.note ?? "",
|
||||||
|
bulky: item?.bulky ?? false,
|
||||||
initialQuantity: "",
|
initialQuantity: "",
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
@@ -93,6 +95,7 @@ export function ProductTypeFormDialog({
|
|||||||
step: v.step,
|
step: v.step,
|
||||||
countEveryDays: v.countEveryDays,
|
countEveryDays: v.countEveryDays,
|
||||||
note: v.note,
|
note: v.note,
|
||||||
|
bulky: v.bulky,
|
||||||
initialQuantity: item ? "" : v.initialQuantity,
|
initialQuantity: item ? "" : v.initialQuantity,
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -285,6 +288,17 @@ export function ProductTypeFormDialog({
|
|||||||
<FieldDescription className="-mt-2">
|
<FieldDescription className="-mt-2">
|
||||||
{f.stepHint} {f.countEveryDaysHint}
|
{f.stepHint} {f.countEveryDaysHint}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="pt-bulky"
|
||||||
|
checked={v.bulky}
|
||||||
|
onCheckedChange={(x) => set("bulky", x === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor="pt-bulky">{f.bulky}</FieldLabel>
|
||||||
|
<FieldDescription>{f.bulkyHint}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||||||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
|||||||
@@ -216,7 +216,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
|||||||
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
||||||
<CardDescription>{t.suggestions.description}</CardDescription>
|
<CardDescription>{t.suggestions.description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{data.suggestions.length ? (
|
||||||
|
<Link href="/alisveris" className="text-sm text-primary hover:underline">
|
||||||
|
{t.suggestions.toShopping}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
{data.suggestions.length === 0 ? (
|
{data.suggestions.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type ProductLean = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky?: boolean;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
@@ -132,6 +133,7 @@ export type ProductTypeView = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
current: number;
|
current: number;
|
||||||
level: StockLevel;
|
level: StockLevel;
|
||||||
@@ -331,6 +333,7 @@ function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTyp
|
|||||||
step: p.step,
|
step: p.step,
|
||||||
countEveryDays: p.countEveryDays,
|
countEveryDays: p.countEveryDays,
|
||||||
note: p.note,
|
note: p.note,
|
||||||
|
bulky: Boolean(p.bulky),
|
||||||
archived: Boolean(p.archivedAt),
|
archived: Boolean(p.archivedAt),
|
||||||
current,
|
current,
|
||||||
level,
|
level,
|
||||||
@@ -807,6 +810,7 @@ const productFields = (input: ProductTypeForm) => ({
|
|||||||
step: input.step,
|
step: input.step,
|
||||||
countEveryDays: input.countEveryDays,
|
countEveryDays: input.countEveryDays,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
|
bulky: input.bulky,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createProductType(
|
export async function createProductType(
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paylaşılan veriler için hafif senkron: sayfa yeniden odaklanınca ve belirli aralıkla sunucu
|
||||||
|
* bileşenlerini yeniler (aile üyeleri aynı listeyi kullanır). Sekme gizliyken yenilemez.
|
||||||
|
*/
|
||||||
|
export function useAutoRefresh(intervalMs: number) {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
if (document.visibilityState === "visible") router.refresh();
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(refresh, intervalMs);
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
document.addEventListener("visibilitychange", refresh);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener("focus", refresh);
|
||||||
|
document.removeEventListener("visibilitychange", refresh);
|
||||||
|
};
|
||||||
|
}, [router, intervalMs]);
|
||||||
|
}
|
||||||
+147
@@ -1012,6 +1012,7 @@ export const tr = {
|
|||||||
title: "Alınması önerilenler",
|
title: "Alınması önerilenler",
|
||||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
||||||
empty: "Eşiğin altına düşen ürün yok.",
|
empty: "Eşiğin altına düşen ürün yok.",
|
||||||
|
toShopping: "Alışveriş listelerinde onayla veya reddet",
|
||||||
suggested: (q: string) => `Öneri: ${q}`,
|
suggested: (q: string) => `Öneri: ${q}`,
|
||||||
wasteReduced: (purchases: number, pct: number) =>
|
wasteReduced: (purchases: number, pct: number) =>
|
||||||
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||||
@@ -1055,6 +1056,9 @@ export const tr = {
|
|||||||
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
||||||
countEveryDays: "Sayım sıklığı (gün)",
|
countEveryDays: "Sayım sıklığı (gün)",
|
||||||
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
||||||
|
bulky: "Ağır / hacimli",
|
||||||
|
bulkyHint:
|
||||||
|
"Su, deterjan gibi taşıması zor ürünler. Akıllı öneri bunları yakın ya da online mağazaya yönlendirir.",
|
||||||
note: "Not",
|
note: "Not",
|
||||||
initialQuantity: "Evdeki mevcut miktar",
|
initialQuantity: "Evdeki mevcut miktar",
|
||||||
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
||||||
@@ -1352,6 +1356,149 @@ export const tr = {
|
|||||||
archived: "Ürün arşivli.",
|
archived: "Ürün arşivli.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shopping: {
|
||||||
|
title: "Alışveriş listeleri",
|
||||||
|
description:
|
||||||
|
"Aile üyeleri aynı listeleri kullanır. Stokta azalan ürünler öneri olarak gelir; akıllı öneri en uygun mağaza ve markayı seçer.",
|
||||||
|
readOnly: "Görüntüleme yetkiniz var; listeleri değiştiremezsiniz.",
|
||||||
|
newList: "Yeni liste",
|
||||||
|
createTitle: "Yeni alışveriş listesi",
|
||||||
|
editTitle: "Listeyi düzenle",
|
||||||
|
active: "Aktif listeler",
|
||||||
|
completed: "Tamamlanan alışverişler",
|
||||||
|
emptyActive: "Aktif liste yok. Yeni bir liste açın.",
|
||||||
|
itemCount: (n: number) => `${n} kalem`,
|
||||||
|
checkedCount: (done: number, total: number) => `${done}/${total} alındı`,
|
||||||
|
updated: (when: string) => `Güncellendi: ${when}`,
|
||||||
|
completedAt: (when: string) => `Tamamlandı: ${when}`,
|
||||||
|
emptyList: "Liste boş.",
|
||||||
|
suggestionsBanner: (n: number) =>
|
||||||
|
`Stokta eşiğin altına düşen ${n} ürün var. Bir listeyi açıp öneriyi onaylayın ya da reddedin.`,
|
||||||
|
fields: {
|
||||||
|
name: "Liste adı",
|
||||||
|
namePlaceholder: "ör. Haftalık market, Ay başı toptan",
|
||||||
|
note: "Not",
|
||||||
|
kind: "Kalem türü",
|
||||||
|
productType: "Ürün tipi",
|
||||||
|
product: "Ürün (marka/paket)",
|
||||||
|
text: "Kalem",
|
||||||
|
textPlaceholder: "ör. Doğum günü mumu",
|
||||||
|
quantity: "Miktar",
|
||||||
|
packages: "Paket adedi",
|
||||||
|
quantityHint: "Boş bırakılırsa en az bir paket.",
|
||||||
|
unit: "Birim",
|
||||||
|
unitPlaceholder: "ör. adet",
|
||||||
|
itemNote: "Not",
|
||||||
|
itemNotePlaceholder: "ör. laktozsuz olsun",
|
||||||
|
},
|
||||||
|
kinds: { type: "Ürün tipi", product: "Belirli marka", text: "Serbest metin" },
|
||||||
|
kindHints: {
|
||||||
|
type: "Markayı akıllı öneri seçer.",
|
||||||
|
product: "Yalnızca bu ürün önerilir.",
|
||||||
|
text: "Stokla ve fiyatlarla bağlanmaz.",
|
||||||
|
},
|
||||||
|
packageUnit: "paket",
|
||||||
|
stockLine: (current: string, target: string) => `Evde ${current} / hedef ${target}`,
|
||||||
|
fromSuggestion: "Stok önerisi",
|
||||||
|
archivedRef: "Arşivlendi",
|
||||||
|
actions: {
|
||||||
|
more: "Diğer işlemler",
|
||||||
|
addItem: "Kalem ekle",
|
||||||
|
edit: "Düzenle",
|
||||||
|
editList: "Listeyi düzenle",
|
||||||
|
deleteList: "Listeyi sil",
|
||||||
|
remove: "Listeden çıkar",
|
||||||
|
moveTo: "Başka listeye taşı",
|
||||||
|
goShopping: "Alışverişe çık",
|
||||||
|
back: "Alışveriş listeleri",
|
||||||
|
backToList: "Listeye dön",
|
||||||
|
},
|
||||||
|
editItemTitle: "Kalemi düzenle",
|
||||||
|
addItemTitle: "Kalem ekle",
|
||||||
|
moveTitle: "Başka listeye taşı",
|
||||||
|
moveDescription: (name: string) => `"${name}" seçilen aktif listeye taşınır.`,
|
||||||
|
moveTarget: "Hedef liste",
|
||||||
|
noOtherLists: "Taşınabilecek başka aktif liste yok.",
|
||||||
|
deleteTitle: "Liste silinsin mi?",
|
||||||
|
deleteDescription: (name: string) => `"${name}" listesi ve kalemleri silinir.`,
|
||||||
|
suggestions: {
|
||||||
|
title: "Stok önerileri",
|
||||||
|
description: "Eşiğin altına düşen ürünler. Onaylananlar önerilen miktarla listeye eklenir.",
|
||||||
|
addAll: "Tümünü ekle",
|
||||||
|
add: "Ekle",
|
||||||
|
dismiss: "Reddet",
|
||||||
|
dismissHint: "Stok yenilenene (yeni alım girilene) kadar önerilmez.",
|
||||||
|
line: (current: string, target: string, pct: number | null) =>
|
||||||
|
`Evde ${current} / hedef ${target}${pct === null ? "" : ` (%${pct})`}`,
|
||||||
|
suggested: (q: string) => `Önerilen: ${q}`,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
title: "Akıllı mağaza önerisi",
|
||||||
|
description:
|
||||||
|
"Son bilinen fiyatlarla. Uzaklık ve taşıma güçlüğü, online teslimat ücreti ve yemek kartı bakiyesi hesaba katılır.",
|
||||||
|
recommended: "Önerilen dağılım",
|
||||||
|
single: "Tek mağaza",
|
||||||
|
singleBest: (store: string, total: string) => `Tek mağaza en iyi seçenek: ${store}, ${total}`,
|
||||||
|
split: (parts: string, total: string) => `Önerilen dağılım: ${parts}: ${total}`,
|
||||||
|
part: (store: string, n: number) => `${store} (${n} ürün)`,
|
||||||
|
saving: (amount: string) => `${amount} tasarruf`,
|
||||||
|
singleMissing: (n: number) => `Tek mağazada ${n} kalem bulunmuyor.`,
|
||||||
|
threshold: (amount: string) =>
|
||||||
|
`Ek mağaza ancak ${amount} üstü tasarruf sağlarsa önerilir (hane ayarı).`,
|
||||||
|
mealCard: (amount: string) => `Yemek kartıyla ödenebilir: ${amount}`,
|
||||||
|
mealCardBalance: (amount: string) => `Yemek kartı bakiyeniz: ${amount}`,
|
||||||
|
delivery: (amount: string) => `Teslimat ${amount}`,
|
||||||
|
freeDelivery: "Teslimat ücretsiz",
|
||||||
|
subtotal: "Ara toplam",
|
||||||
|
total: "Tahmini toplam",
|
||||||
|
line: (packages: string, price: string) => `${packages} × ${price}`,
|
||||||
|
unpriced: "Fiyatı bilinmeyenler",
|
||||||
|
unpricedHint: "Bu kalemler için mağaza fiyatı girilmemiş.",
|
||||||
|
cheapestAt: (store: string, unitPrice: string) =>
|
||||||
|
`En ucuz birim fiyat: ${store}, ${unitPrice}`,
|
||||||
|
empty: "Öneri için listede fiyatı bilinen ürün yok.",
|
||||||
|
textItems: "Serbest metin kalemleri öneriye katılmaz.",
|
||||||
|
missing: "Bu planda alınamayan",
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
title: "Alışveriş modu",
|
||||||
|
progress: (done: number, total: number) => `${done}/${total} sepette`,
|
||||||
|
other: "Diğer",
|
||||||
|
otherHint: "Fiyatı bilinmeyen ve serbest metin kalemleri",
|
||||||
|
inCart: "Sepette",
|
||||||
|
allDone: "Hepsi sepette.",
|
||||||
|
planChoice: "Plan",
|
||||||
|
check: (label: string) => `${label}: sepete alındı olarak işaretle`,
|
||||||
|
refreshed: "Liste güncellendi",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Liste kaydedildi.",
|
||||||
|
deleted: "Liste silindi.",
|
||||||
|
itemAdded: "Kalem eklendi.",
|
||||||
|
itemSaved: "Kalem güncellendi.",
|
||||||
|
itemRemoved: "Kalem listeden çıkarıldı.",
|
||||||
|
moved: "Kalem taşındı.",
|
||||||
|
suggestionsAdded: (n: number) => `${n} öneri listeye eklendi.`,
|
||||||
|
dismissed: "Öneri reddedildi.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
quantity: "Geçerli bir miktar girin (ör. 2 veya 1,5)",
|
||||||
|
productType: "Aktif bir ürün tipi seçin",
|
||||||
|
product: "Aktif bir ürün seçin",
|
||||||
|
text: "Kalem adını yazın",
|
||||||
|
duplicate: "Bu ürün listede zaten var; miktarını düzenleyin.",
|
||||||
|
duplicateIn: (name: string) => `Bu ürün "${name}" listesinde zaten var.`,
|
||||||
|
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
||||||
|
sameList: "Kalem zaten bu listede.",
|
||||||
|
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: "Alışveriş",
|
||||||
|
empty: "Aktif liste yok.",
|
||||||
|
open: "Listeleri aç",
|
||||||
|
suggestions: (n: number) => `${n} stok önerisi bekliyor`,
|
||||||
|
},
|
||||||
|
},
|
||||||
productPrices: {
|
productPrices: {
|
||||||
add: "Fiyat gir",
|
add: "Fiyat gir",
|
||||||
addTitle: "Fiyat gir",
|
addTitle: "Fiyat gir",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const AUDIT_ACTIONS = [
|
|||||||
"product.archive",
|
"product.archive",
|
||||||
"product.unarchive",
|
"product.unarchive",
|
||||||
"price.delete",
|
"price.delete",
|
||||||
|
"shoppingList.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const productTypeSchema = new Schema(
|
|||||||
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
||||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||||
note: { type: String, default: "", maxlength: 300 },
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
/** Ağır/hacimli (su, deterjan): akıllı öneri zahmetli fiziksel mağazadan uzak tutar (Faz 13). */
|
||||||
|
bulky: { type: Boolean, default: false },
|
||||||
|
/**
|
||||||
|
* Eşik önerisi reddedildi: bu tarihten sonra alım hareketi gelene (stok yenilenene) kadar
|
||||||
|
* öneri gösterilmez (Faz 13).
|
||||||
|
*/
|
||||||
|
suggestionDismissedAt: { type: Date, default: null },
|
||||||
archivedAt: { type: Date, default: null },
|
archivedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
SHOPPING_ITEM_KINDS,
|
||||||
|
SHOPPING_ITEM_SOURCES,
|
||||||
|
SHOPPING_LIST_STATUSES,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş kalemi (Bölüm 6.12): ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin.
|
||||||
|
* Miktar binde bir: ürün tipinde tipin biriminde, üründe paket adedi, serbest metinde `unit` ile.
|
||||||
|
* `checked`: alışveriş modunda sepete alındı.
|
||||||
|
*/
|
||||||
|
const shoppingItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
kind: { type: String, enum: SHOPPING_ITEM_KINDS, required: true },
|
||||||
|
productTypeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
productId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
text: { type: String, default: "", trim: true, maxlength: 120 },
|
||||||
|
/** Boşsa "1 paket" kabul edilir. */
|
||||||
|
quantity: { type: Number, default: null, min: 0 },
|
||||||
|
/** Serbest metin kaleminin birimi. */
|
||||||
|
unit: { type: String, default: "", trim: true, maxlength: 20 },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 200 },
|
||||||
|
source: { type: String, enum: SHOPPING_ITEM_SOURCES, default: "manual" },
|
||||||
|
checked: { type: Boolean, default: false },
|
||||||
|
checkedBy: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
checkedAt: { type: Date, default: null },
|
||||||
|
addedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
addedAt: { type: Date, default: () => new Date() },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alışveriş listesi; hane içinde ortak. Birden çok aktif liste olabilir. Silme: soft delete. */
|
||||||
|
const shoppingListSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
status: { type: String, enum: SHOPPING_LIST_STATUSES, default: "active" },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 300 },
|
||||||
|
items: { type: [shoppingItemSchema], default: [] },
|
||||||
|
completedAt: { type: Date, default: null },
|
||||||
|
deletedAt: { 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: "shoppingLists" },
|
||||||
|
);
|
||||||
|
|
||||||
|
shoppingListSchema.index({ householdId: 1, deletedAt: 1, status: 1, updatedAt: -1 });
|
||||||
|
shoppingListSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ShoppingList = InferSchemaType<typeof shoppingListSchema>;
|
||||||
|
|
||||||
|
export const ShoppingListModel: Model<ShoppingList> =
|
||||||
|
(models.ShoppingList as Model<ShoppingList> | undefined) ??
|
||||||
|
model<ShoppingList>("ShoppingList", shoppingListSchema);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
|
const s = tr.shopping.validation;
|
||||||
|
const id = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
const optionalId = z
|
||||||
|
.string()
|
||||||
|
.regex(/^([a-f0-9]{24})?$/i)
|
||||||
|
.transform((x) => (x === "" ? null : x));
|
||||||
|
|
||||||
|
/** Boş → null (1 paket / belirtilmemiş); dolu → binde bir pozitif tamsayı. */
|
||||||
|
const optionalQuantity = z.preprocess(
|
||||||
|
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const q = parseQuantity(x);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const shoppingListFormSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
});
|
||||||
|
export type ShoppingListFormInput = z.input<typeof shoppingListFormSchema>;
|
||||||
|
export type ShoppingListForm = z.output<typeof shoppingListFormSchema>;
|
||||||
|
|
||||||
|
/** Kalem: ürün tipi, belirli ürün (marka/paket) ya da serbest metin. */
|
||||||
|
export const shoppingItemFormSchema = z
|
||||||
|
.object({
|
||||||
|
kind: z.enum(["type", "product", "text"]),
|
||||||
|
productTypeId: optionalId.default(""),
|
||||||
|
productId: optionalId.default(""),
|
||||||
|
text: z.string().trim().max(120).default(""),
|
||||||
|
quantity: optionalQuantity.default(null),
|
||||||
|
unit: z.string().trim().max(20).default(""),
|
||||||
|
note: z.string().trim().max(200).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.kind === "type" && !x.productTypeId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["productTypeId"], message: s.productType });
|
||||||
|
if (x.kind === "product" && !x.productId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["productId"], message: s.product });
|
||||||
|
if (x.kind === "text" && !x.text)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["text"], message: s.text });
|
||||||
|
})
|
||||||
|
.transform((x) => ({
|
||||||
|
...x,
|
||||||
|
productTypeId: x.kind === "type" ? x.productTypeId : null,
|
||||||
|
productId: x.kind === "product" ? x.productId : null,
|
||||||
|
text: x.kind === "text" ? x.text : "",
|
||||||
|
unit: x.kind === "text" ? x.unit : "",
|
||||||
|
}));
|
||||||
|
export type ShoppingItemFormInput = z.input<typeof shoppingItemFormSchema>;
|
||||||
|
export type ShoppingItemForm = z.output<typeof shoppingItemFormSchema>;
|
||||||
|
|
||||||
|
/** Kalem düzenleme: miktar ve not (serbest metinde metin ve birim de). */
|
||||||
|
export const shoppingItemUpdateSchema = z.object({
|
||||||
|
quantity: optionalQuantity.default(null),
|
||||||
|
text: z.string().trim().max(120).default(""),
|
||||||
|
unit: z.string().trim().max(20).default(""),
|
||||||
|
note: z.string().trim().max(200).default(""),
|
||||||
|
});
|
||||||
|
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
||||||
|
|
||||||
|
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
|
||||||
@@ -77,6 +77,8 @@ export const productTypeFormSchema = z.object({
|
|||||||
step: quantity(),
|
step: quantity(),
|
||||||
countEveryDays,
|
countEveryDays,
|
||||||
note: z.string().trim().max(300).default(""),
|
note: z.string().trim().max(300).default(""),
|
||||||
|
/** Ağır/hacimli: akıllı mağaza önerisinde taşıma cezası. */
|
||||||
|
bulky: z.boolean().default(false),
|
||||||
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
||||||
initialQuantity: optionalQuantity.default(null),
|
initialQuantity: optionalQuantity.default(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
BULKY_PENALTY_PER_POINT,
|
||||||
|
planShopping,
|
||||||
|
VISIT_PENALTY_PER_POINT,
|
||||||
|
type PlanInput,
|
||||||
|
type PlanItem,
|
||||||
|
type PlanOffer,
|
||||||
|
type PlanStore,
|
||||||
|
} from "./shopping-plan";
|
||||||
|
import { effortScore } from "./stores";
|
||||||
|
|
||||||
|
const store = (id: string, over: Partial<PlanStore> = {}): PlanStore => ({
|
||||||
|
id,
|
||||||
|
kind: "market",
|
||||||
|
distanceM: 0,
|
||||||
|
carryDifficulty: 1,
|
||||||
|
deliveryFeeMinor: null,
|
||||||
|
freeDeliveryMinMinor: null,
|
||||||
|
mealCardProviders: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
quantity = 1000,
|
||||||
|
over: Partial<PlanItem> = {},
|
||||||
|
): PlanItem => ({
|
||||||
|
id,
|
||||||
|
productTypeId: type,
|
||||||
|
quantity,
|
||||||
|
productId: null,
|
||||||
|
bulky: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const offer = (
|
||||||
|
storeId: string,
|
||||||
|
type: string,
|
||||||
|
priceMinor: number,
|
||||||
|
over: Partial<PlanOffer> = {},
|
||||||
|
): PlanOffer => ({
|
||||||
|
productId: `${type}-p`,
|
||||||
|
productTypeId: type,
|
||||||
|
storeId,
|
||||||
|
priceMinor,
|
||||||
|
inTypeUnit: 1000,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = (over: Partial<PlanInput>): PlanInput => ({
|
||||||
|
items: [],
|
||||||
|
offers: [],
|
||||||
|
stores: [],
|
||||||
|
mealCards: [],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storesOf = (o: { stores: { storeId: string }[] } | null) => o?.stores.map((s) => s.storeId);
|
||||||
|
|
||||||
|
describe("planShopping", () => {
|
||||||
|
it("fiyatı olmayan kalemler ayrılır; hiç fiyat yoksa plan yok", () => {
|
||||||
|
const r = planShopping(base({ items: [item("i1", "t1")], stores: [store("A")] }));
|
||||||
|
expect(r.recommended).toBeNull();
|
||||||
|
expect(r.single).toBeNull();
|
||||||
|
expect(r.unpriced).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paket sayısı ihtiyaca göre yukarı yuvarlanır; mağazada en az harcamalı paket seçilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1", 2500)],
|
||||||
|
stores: [store("A")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 3000, { productId: "small", inTypeUnit: 1000 }),
|
||||||
|
// Birim fiyatı daha ucuz ama 5 birimlik paket: harcama 12.000 > 3 × 3.000.
|
||||||
|
offer("A", "t1", 12_000, { productId: "big", inTypeUnit: 5000 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const line = r.recommended!.stores[0]!.lines[0]!;
|
||||||
|
expect(line).toMatchObject({ productId: "small", packages: 3, costMinor: 9000 });
|
||||||
|
// En ucuz birim fiyat bilgisi büyük paketi gösterir.
|
||||||
|
expect(r.cheapest.i1!.productId).toBe("big");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("belirli marka istenen kalemde yalnızca o ürünün fiyatı kullanılır", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1", 1000, { productId: "ariel" })],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 5000, { productId: "ariel" }),
|
||||||
|
offer("B", "t1", 3000, { productId: "omo" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["A"]);
|
||||||
|
expect(r.recommended!.totalMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tasarruf eşiği aşılmazsa tek mağazada toplar", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 10_000),
|
||||||
|
offer("A", "t2", 10_000),
|
||||||
|
offer("B", "t1", 9_000),
|
||||||
|
offer("B", "t2", 10_500),
|
||||||
|
],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A: 200 TL, B: 195 TL tek başına; ikisi birden 190 TL (5 TL fark < 100 TL eşik).
|
||||||
|
expect(storesOf(r.single)).toEqual(["B"]);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["B"]);
|
||||||
|
expect(r.savingMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tasarruf eşiği aşılırsa ek mağaza önerilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2"), item("i3", "t3")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 50_000),
|
||||||
|
offer("A", "t2", 50_000),
|
||||||
|
offer("A", "t3", 50_000),
|
||||||
|
offer("B", "t1", 30_000),
|
||||||
|
offer("B", "t2", 60_000),
|
||||||
|
offer("B", "t3", 60_000),
|
||||||
|
],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(r.single)).toEqual(["A"]);
|
||||||
|
expect(r.recommended!.stores.map((s) => [s.storeId, s.lines.length])).toEqual([
|
||||||
|
["A", 2],
|
||||||
|
["B", 1],
|
||||||
|
]);
|
||||||
|
expect(r.recommended!.totalMinor).toBe(130_000);
|
||||||
|
expect(r.savingMinor).toBe(20_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tek mağaza her şeyi karşılamıyorsa en az mağazalı eksiksiz plan önerilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [offer("A", "t1", 1000), offer("B", "t2", 1000)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.single!.missing).toHaveLength(1);
|
||||||
|
expect(r.recommended!.missing).toEqual([]);
|
||||||
|
expect(r.recommended!.stores).toHaveLength(2);
|
||||||
|
expect(r.savingMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uzaklık ve taşıma güçlüğü ziyaret cezası olarak puana eklenir", () => {
|
||||||
|
const far = store("FAR", { distanceM: 20_000, carryDifficulty: 5 });
|
||||||
|
const near = store("NEAR");
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1")],
|
||||||
|
stores: [far, near],
|
||||||
|
offers: [offer("FAR", "t1", 10_000), offer("NEAR", "t1", 13_000)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// FAR 30 TL ucuz ama zahmet 100 → 50 TL ceza.
|
||||||
|
expect(effortScore(far) * VISIT_PENALTY_PER_POINT).toBe(5000);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["NEAR"]);
|
||||||
|
expect(storesOf(r.single)).toEqual(["NEAR"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hacimli kalemler zahmetli fiziksel mağaza yerine online'a yönelir", () => {
|
||||||
|
const hard = store("HARD", { distanceM: 10_000, carryDifficulty: 5 });
|
||||||
|
const online = store("ONLINE", { kind: "online", distanceM: null, deliveryFeeMinor: 0 });
|
||||||
|
const items = [item("water", "w", 1000, { bulky: true }), item("bread", "b")];
|
||||||
|
const offers = [
|
||||||
|
offer("HARD", "w", 10_000),
|
||||||
|
offer("ONLINE", "w", 10_500),
|
||||||
|
offer("HARD", "b", 1000),
|
||||||
|
];
|
||||||
|
const bulky = planShopping(
|
||||||
|
base({ items, stores: [hard, online], offers, extraStoreThresholdMinor: 0 }),
|
||||||
|
);
|
||||||
|
const line = bulky.recommended!.stores.find((s) => s.storeId === "ONLINE")?.lines[0];
|
||||||
|
expect(line?.itemId).toBe("water");
|
||||||
|
// Aynı kalem hacimli değilse 5 TL ucuz olan HARD seçilir.
|
||||||
|
const plain = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("water", "w"), item("bread", "b")],
|
||||||
|
stores: [hard, online],
|
||||||
|
offers,
|
||||||
|
extraStoreThresholdMinor: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(plain.recommended)).toEqual(["HARD"]);
|
||||||
|
expect(effortScore(hard) * BULKY_PENALTY_PER_POINT).toBeGreaterThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online teslimat ücreti ücretsiz teslimat limitinin altında eklenir", () => {
|
||||||
|
const online = store("ON", {
|
||||||
|
kind: "online",
|
||||||
|
distanceM: null,
|
||||||
|
deliveryFeeMinor: 3000,
|
||||||
|
freeDeliveryMinMinor: 50_000,
|
||||||
|
});
|
||||||
|
const shop = store("SHOP");
|
||||||
|
const offers = [offer("ON", "t1", 10_000), offer("SHOP", "t1", 11_000)];
|
||||||
|
const small = planShopping(base({ items: [item("i1", "t1")], stores: [online, shop], offers }));
|
||||||
|
// 100 TL + 30 TL teslimat > 110 TL.
|
||||||
|
expect(storesOf(small.recommended)).toEqual(["SHOP"]);
|
||||||
|
const big = planShopping(
|
||||||
|
base({ items: [item("i1", "t1", 5000)], stores: [online, shop], offers }),
|
||||||
|
);
|
||||||
|
// 500 TL ≥ limit: teslimat ücretsiz, online 50 TL ucuz.
|
||||||
|
expect(storesOf(big.recommended)).toEqual(["ON"]);
|
||||||
|
expect(big.recommended!.stores[0]!.deliveryFeeMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yemek kartı bakiyesi varsa fiyatı yakın kalemler kart geçen mağazaya yönelir", () => {
|
||||||
|
const meal = store("MEAL", { mealCardProviders: ["multinet"] });
|
||||||
|
const other = store("OTHER");
|
||||||
|
const offers = [offer("MEAL", "t1", 10_300), offer("OTHER", "t1", 10_000)];
|
||||||
|
const items = [item("i1", "t1")];
|
||||||
|
const without = planShopping(base({ items, stores: [meal, other], offers }));
|
||||||
|
expect(storesOf(without.recommended)).toEqual(["OTHER"]);
|
||||||
|
const withCard = planShopping(
|
||||||
|
base({
|
||||||
|
items,
|
||||||
|
stores: [meal, other],
|
||||||
|
offers,
|
||||||
|
mealCards: [{ provider: "multinet", balanceMinor: 50_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(withCard.recommended)).toEqual(["MEAL"]);
|
||||||
|
expect(withCard.recommended!.mealCardMinor).toBe(10_300);
|
||||||
|
// Kart sağlayıcısı mağazada geçmiyorsa öncelik yok.
|
||||||
|
const wrong = planShopping(
|
||||||
|
base({
|
||||||
|
items,
|
||||||
|
stores: [meal, other],
|
||||||
|
offers,
|
||||||
|
mealCards: [{ provider: "sodexo", balanceMinor: 50_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(wrong.recommended)).toEqual(["OTHER"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yemek kartı bakiyesi mağazalar arasında bir kez harcanır", () => {
|
||||||
|
const a = store("A", { mealCardProviders: ["multinet"] });
|
||||||
|
const b = store("B", { mealCardProviders: ["multinet"] });
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [a, b],
|
||||||
|
offers: [offer("A", "t1", 30_000), offer("B", "t2", 20_000)],
|
||||||
|
mealCards: [{ provider: "multinet", balanceMinor: 40_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.recommended!.mealCardMinor).toBe(40_000);
|
||||||
|
expect(r.recommended!.stores.map((s) => s.mealCardMinor).sort()).toEqual([10_000, 30_000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { effortScore, type StoreKind } from "./stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Akıllı mağaza önerisi (Bölüm 6.12). Saf fonksiyon; son bilinen fiyatlarla listedeki her kalemi
|
||||||
|
* bir mağazaya ve markaya dağıtır.
|
||||||
|
*
|
||||||
|
* Kalem maliyeti: ihtiyacı karşılayan paket sayısı × paket fiyatı (gerçek harcama). Bir mağazada
|
||||||
|
* birden çok marka/paket varsa en az harcamalı olan, eşitlikte birim fiyatı düşük olan seçilir.
|
||||||
|
*
|
||||||
|
* Planın puanı (küçük olan iyi) = harcama + teslimat ücretleri + zahmet cezaları − yemek kartı payı:
|
||||||
|
* - Ziyaret cezası: fiziksel mağaza başına zahmet skoru × ZİYARET_KATSAYISI (online: 0).
|
||||||
|
* - Hacimli kalem cezası: fiziksel mağazada hacimli kalem başına zahmet skoru × HACİMLİ_KATSAYI.
|
||||||
|
* - Online teslimat: ara toplam ücretsiz teslimat alt limitinin altındaysa (limit yoksa her
|
||||||
|
* zaman) teslimat ücreti eklenir.
|
||||||
|
* - Yemek kartı önceliği: yemek kartı geçen mağazalarda kartla ödenebilecek tutarın
|
||||||
|
* MEAL_CARD_PREFERENCE_PCT'si puandan düşülür; böylece fiyatı yakın kalemler karta yönelir.
|
||||||
|
*
|
||||||
|
* Seçim: tüm fiyatlı kalemleri karşılayan en az mağazalı plandan başlanır; bir mağaza daha
|
||||||
|
* eklemek ancak puanı eşikten (hane ayarı) fazla düşürüyorsa kabul edilir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ziyaret cezası: zahmet puanı başına kuruş (zahmet 100 → 50 TL). */
|
||||||
|
export const VISIT_PENALTY_PER_POINT = 50;
|
||||||
|
/** Hacimli kalem cezası: zahmet puanı başına kuruş (zahmet 100 → kalem başına 15 TL). */
|
||||||
|
export const BULKY_PENALTY_PER_POINT = 15;
|
||||||
|
/** Yemek kartıyla ödenebilecek tutarın puandan düşülen yüzdesi. */
|
||||||
|
export const MEAL_CARD_PREFERENCE_PCT = 10;
|
||||||
|
/** Bir planda en fazla bu kadar mağaza. */
|
||||||
|
export const MAX_PLAN_STORES = 4;
|
||||||
|
|
||||||
|
export type PlanStore = {
|
||||||
|
id: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
/** Yemek kartı geçmiyorsa boş. */
|
||||||
|
mealCardProviders: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanOffer = {
|
||||||
|
productId: string;
|
||||||
|
productTypeId: string;
|
||||||
|
storeId: string;
|
||||||
|
/** Paket fiyatı (kuruş). */
|
||||||
|
priceMinor: number;
|
||||||
|
/** Bir paketin ürün tipi birimindeki miktarı (binde bir). */
|
||||||
|
inTypeUnit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanItem = {
|
||||||
|
id: string;
|
||||||
|
productTypeId: string;
|
||||||
|
/** Gereken miktar, ürün tipi biriminde (binde bir). */
|
||||||
|
quantity: number;
|
||||||
|
/** Belirli marka/ürün istenmişse yalnızca o ürünün fiyatları. */
|
||||||
|
productId: string | null;
|
||||||
|
bulky: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanMealCard = { provider: string; balanceMinor: number };
|
||||||
|
|
||||||
|
export type PlanInput = {
|
||||||
|
items: readonly PlanItem[];
|
||||||
|
offers: readonly PlanOffer[];
|
||||||
|
stores: readonly PlanStore[];
|
||||||
|
mealCards: readonly PlanMealCard[];
|
||||||
|
/** Ek mağaza eşiği (kuruş). */
|
||||||
|
extraStoreThresholdMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanLine = {
|
||||||
|
itemId: string;
|
||||||
|
productId: string;
|
||||||
|
storeId: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş). */
|
||||||
|
unitPriceMinor: number;
|
||||||
|
bulkyPenaltyMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanStoreGroup = {
|
||||||
|
storeId: string;
|
||||||
|
lines: PlanLine[];
|
||||||
|
subtotalMinor: number;
|
||||||
|
deliveryFeeMinor: number;
|
||||||
|
visitPenaltyMinor: number;
|
||||||
|
/** Bu mağazada yemek kartıyla ödenebilecek tutar. */
|
||||||
|
mealCardMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanOption = {
|
||||||
|
stores: PlanStoreGroup[];
|
||||||
|
/** Harcama + teslimat. */
|
||||||
|
totalMinor: number;
|
||||||
|
/** Zahmet cezaları (ziyaret + hacimli). */
|
||||||
|
penaltyMinor: number;
|
||||||
|
mealCardMinor: number;
|
||||||
|
/** Karşılaştırma puanı (kesirli olabilir). */
|
||||||
|
score: number;
|
||||||
|
/** Bu planda alınamayan (fiyatlı) kalemler. */
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanResult = {
|
||||||
|
/** Tek mağazanın en iyisi (hepsini karşılayan yoksa en çok kalemi karşılayan). */
|
||||||
|
single: PlanOption | null;
|
||||||
|
/** Önerilen dağılım; tek mağaza en iyiyse onunla aynı. */
|
||||||
|
recommended: PlanOption | null;
|
||||||
|
/** Tek mağazaya göre harcama farkı (pozitif = tasarruf); karşılaştırılamıyorsa null. */
|
||||||
|
savingMinor: number | null;
|
||||||
|
/** Hiçbir mağazada fiyatı bilinmeyen kalemler. */
|
||||||
|
unpriced: string[];
|
||||||
|
/** Kalem başına birim fiyatı en düşük teklif (mağaza ve marka). */
|
||||||
|
cheapest: Record<string, PlanLine>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitOf = (o: PlanOffer) => (o.priceMinor * 1000) / o.inTypeUnit;
|
||||||
|
|
||||||
|
/** Bir teklifle kalemi karşılamak: paket sayısı ve harcama. */
|
||||||
|
function lineFor(item: PlanItem, o: PlanOffer, store: PlanStore): PlanLine {
|
||||||
|
const packages = Math.max(1, Math.ceil(item.quantity / o.inTypeUnit));
|
||||||
|
const physical = store.kind !== "online";
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
productId: o.productId,
|
||||||
|
storeId: o.storeId,
|
||||||
|
packages,
|
||||||
|
priceMinor: o.priceMinor,
|
||||||
|
costMinor: packages * o.priceMinor,
|
||||||
|
unitPriceMinor: unitOf(o),
|
||||||
|
bulkyPenaltyMinor: item.bulky && physical ? effortScore(store) * BULKY_PENALTY_PER_POINT : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const better = (a: PlanLine, b: PlanLine) =>
|
||||||
|
a.costMinor !== b.costMinor ? a.costMinor < b.costMinor : a.unitPriceMinor < b.unitPriceMinor;
|
||||||
|
|
||||||
|
/** Mağazalardaki yemek kartı payı: kart bakiyeleri bir kez harcanır, büyük ara toplam önce. */
|
||||||
|
function allocateMealCards(
|
||||||
|
groups: PlanStoreGroup[],
|
||||||
|
stores: Map<string, PlanStore>,
|
||||||
|
cards: readonly PlanMealCard[],
|
||||||
|
) {
|
||||||
|
const left = cards.map((c) => ({ ...c }));
|
||||||
|
const order = [...groups].sort((a, b) => b.subtotalMinor - a.subtotalMinor);
|
||||||
|
for (const g of order) {
|
||||||
|
const accepted = new Set(stores.get(g.storeId)!.mealCardProviders);
|
||||||
|
let need = g.subtotalMinor + g.deliveryFeeMinor;
|
||||||
|
for (const c of left) {
|
||||||
|
if (need <= 0) break;
|
||||||
|
if (!accepted.has(c.provider) || c.balanceMinor <= 0) continue;
|
||||||
|
const use = Math.min(need, c.balanceMinor);
|
||||||
|
c.balanceMinor -= use;
|
||||||
|
need -= use;
|
||||||
|
g.mealCardMinor += use;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* subsets(n: number, k: number, start = 0, acc: number[] = []): Generator<number[]> {
|
||||||
|
if (acc.length === k) {
|
||||||
|
yield [...acc];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = start; i <= n - (k - acc.length); i++) {
|
||||||
|
acc.push(i);
|
||||||
|
yield* subsets(n, k, i + 1, acc);
|
||||||
|
acc.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planShopping(input: PlanInput): PlanResult {
|
||||||
|
const storeById = new Map(input.stores.map((s) => [s.id, s]));
|
||||||
|
// Kalem × mağaza en iyi satır.
|
||||||
|
const best = new Map<string, Map<string, PlanLine>>();
|
||||||
|
const cheapest: Record<string, PlanLine> = {};
|
||||||
|
for (const item of input.items) {
|
||||||
|
const byStore = new Map<string, PlanLine>();
|
||||||
|
for (const o of input.offers) {
|
||||||
|
if (o.inTypeUnit <= 0 || o.productTypeId !== item.productTypeId) continue;
|
||||||
|
if (item.productId && o.productId !== item.productId) continue;
|
||||||
|
const store = storeById.get(o.storeId);
|
||||||
|
if (!store) continue;
|
||||||
|
const line = lineFor(item, o, store);
|
||||||
|
const prev = byStore.get(o.storeId);
|
||||||
|
if (!prev || better(line, prev)) byStore.set(o.storeId, line);
|
||||||
|
const c = cheapest[item.id];
|
||||||
|
if (!c || line.unitPriceMinor < c.unitPriceMinor) cheapest[item.id] = line;
|
||||||
|
}
|
||||||
|
best.set(item.id, byStore);
|
||||||
|
}
|
||||||
|
const priced = input.items.filter((i) => best.get(i.id)!.size > 0);
|
||||||
|
const unpriced = input.items.filter((i) => best.get(i.id)!.size === 0).map((i) => i.id);
|
||||||
|
const candidates = input.stores.filter((s) => priced.some((i) => best.get(i.id)!.has(s.id)));
|
||||||
|
|
||||||
|
if (priced.length === 0 || candidates.length === 0)
|
||||||
|
return { single: null, recommended: null, savingMinor: null, unpriced, cheapest };
|
||||||
|
|
||||||
|
function evaluate(chosen: PlanStore[]): PlanOption {
|
||||||
|
const groups = new Map<string, PlanStoreGroup>();
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const item of priced) {
|
||||||
|
const options = best.get(item.id)!;
|
||||||
|
let pick: PlanLine | null = null;
|
||||||
|
for (const s of chosen) {
|
||||||
|
const line = options.get(s.id);
|
||||||
|
if (!line) continue;
|
||||||
|
if (
|
||||||
|
!pick ||
|
||||||
|
line.costMinor + line.bulkyPenaltyMinor < pick.costMinor + pick.bulkyPenaltyMinor ||
|
||||||
|
(line.costMinor + line.bulkyPenaltyMinor === pick.costMinor + pick.bulkyPenaltyMinor &&
|
||||||
|
line.unitPriceMinor < pick.unitPriceMinor)
|
||||||
|
)
|
||||||
|
pick = line;
|
||||||
|
}
|
||||||
|
if (!pick) {
|
||||||
|
missing.push(item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const g = groups.get(pick.storeId) ?? {
|
||||||
|
storeId: pick.storeId,
|
||||||
|
lines: [],
|
||||||
|
subtotalMinor: 0,
|
||||||
|
deliveryFeeMinor: 0,
|
||||||
|
visitPenaltyMinor: 0,
|
||||||
|
mealCardMinor: 0,
|
||||||
|
};
|
||||||
|
g.lines.push(pick);
|
||||||
|
g.subtotalMinor += pick.costMinor;
|
||||||
|
groups.set(pick.storeId, g);
|
||||||
|
}
|
||||||
|
const list = [...groups.values()];
|
||||||
|
for (const g of list) {
|
||||||
|
const s = storeById.get(g.storeId)!;
|
||||||
|
if (s.kind === "online") {
|
||||||
|
const fee = s.deliveryFeeMinor ?? 0;
|
||||||
|
const free = s.freeDeliveryMinMinor;
|
||||||
|
g.deliveryFeeMinor = free !== null && g.subtotalMinor >= free ? 0 : fee;
|
||||||
|
} else g.visitPenaltyMinor = effortScore(s) * VISIT_PENALTY_PER_POINT;
|
||||||
|
}
|
||||||
|
allocateMealCards(list, storeById, input.mealCards);
|
||||||
|
const totalMinor = list.reduce((a, g) => a + g.subtotalMinor + g.deliveryFeeMinor, 0);
|
||||||
|
const penaltyMinor = list.reduce(
|
||||||
|
(a, g) => a + g.visitPenaltyMinor + g.lines.reduce((b, l) => b + l.bulkyPenaltyMinor, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const mealCardMinor = list.reduce((a, g) => a + g.mealCardMinor, 0);
|
||||||
|
return {
|
||||||
|
stores: list.sort(
|
||||||
|
(a, b) => b.lines.length - a.lines.length || b.subtotalMinor - a.subtotalMinor,
|
||||||
|
),
|
||||||
|
totalMinor,
|
||||||
|
penaltyMinor,
|
||||||
|
mealCardMinor,
|
||||||
|
score: totalMinor + penaltyMinor - (mealCardMinor * MEAL_CARD_PREFERENCE_PCT) / 100,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boyut başına en iyi tam (eksiksiz) plan; tek mağazada eksiksiz yoksa en çok kalemi karşılayan.
|
||||||
|
const bestBySize: (PlanOption | null)[] = [];
|
||||||
|
let single: PlanOption | null = null;
|
||||||
|
const maxK = Math.min(MAX_PLAN_STORES, candidates.length);
|
||||||
|
for (let k = 1; k <= maxK; k++) {
|
||||||
|
let top: PlanOption | null = null;
|
||||||
|
for (const idx of subsets(candidates.length, k)) {
|
||||||
|
const option = evaluate(idx.map((i) => candidates[i]!));
|
||||||
|
// Kullanılmayan mağaza içeren alt küme daha küçük boyutta zaten değerlendirildi.
|
||||||
|
if (option.stores.length < k) continue;
|
||||||
|
if (
|
||||||
|
k === 1 &&
|
||||||
|
(!single ||
|
||||||
|
option.missing.length < single.missing.length ||
|
||||||
|
(option.missing.length === single.missing.length && option.score < single.score))
|
||||||
|
)
|
||||||
|
single = option;
|
||||||
|
if (option.missing.length === 0 && (!top || option.score < top.score)) top = option;
|
||||||
|
}
|
||||||
|
bestBySize[k] = top;
|
||||||
|
}
|
||||||
|
|
||||||
|
let k = bestBySize.findIndex((o, i) => i > 0 && o !== null);
|
||||||
|
let recommended: PlanOption | null = k > 0 ? bestBySize[k]! : null;
|
||||||
|
// Tüm alt kümeler sınırı aştıysa (kalemler çok dağınık): en iyi eksikli plan.
|
||||||
|
if (!recommended) recommended = single;
|
||||||
|
else
|
||||||
|
while (k + 1 <= maxK) {
|
||||||
|
const next = bestBySize[k + 1];
|
||||||
|
if (!next || recommended.score - next.score <= input.extraStoreThresholdMinor) break;
|
||||||
|
recommended = next;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparable =
|
||||||
|
single && recommended && single.missing.length === 0 && recommended.missing.length === 0;
|
||||||
|
return {
|
||||||
|
single,
|
||||||
|
recommended,
|
||||||
|
savingMinor: comparable ? single!.totalMinor - recommended!.totalMinor : null,
|
||||||
|
unpriced,
|
||||||
|
cheapest,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { requiredQuantity, suggestionHidden } from "./shopping";
|
||||||
|
|
||||||
|
describe("requiredQuantity", () => {
|
||||||
|
it("ürün tipi kaleminde girilen miktar; boşsa 0", () => {
|
||||||
|
expect(requiredQuantity({ kind: "type", quantity: 2500 }, null)).toBe(2500);
|
||||||
|
expect(requiredQuantity({ kind: "type", quantity: null }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ürün kaleminde paket adedi × paketin tip karşılığı; boşsa 1 paket", () => {
|
||||||
|
// 2 paket × 50 yıkama
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: 2000 }, 50_000)).toBe(100_000);
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: null }, 500)).toBe(500);
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: 2000 }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestionHidden", () => {
|
||||||
|
const d = (s: string) => new Date(s);
|
||||||
|
it("reddedilmemişse görünür", () => {
|
||||||
|
expect(suggestionHidden(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
it("redden sonra alım yoksa gizli, alım gelince yeniden görünür", () => {
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), null)).toBe(true);
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), d("2026-10-20"))).toBe(true);
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), d("2026-11-03"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
|
/** Alışveriş listeleri (Bölüm 6.12): sabitler ve saf yardımcılar. */
|
||||||
|
|
||||||
|
export const SHOPPING_LIST_STATUSES = ["active", "completed"] as const;
|
||||||
|
export type ShoppingListStatus = (typeof SHOPPING_LIST_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SHOPPING_ITEM_KINDS = ["type", "product", "text"] as const;
|
||||||
|
export type ShoppingItemKind = (typeof SHOPPING_ITEM_KINDS)[number];
|
||||||
|
|
||||||
|
export const SHOPPING_ITEM_SOURCES = ["manual", "suggestion"] as const;
|
||||||
|
export type ShoppingItemSource = (typeof SHOPPING_ITEM_SOURCES)[number];
|
||||||
|
|
||||||
|
/** Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve bu aralıkla yenilenir. */
|
||||||
|
export const SHOPPING_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planlayıcı için gereken miktar (ürün tipi biriminde, binde bir):
|
||||||
|
* - ürün tipi kalemi: girilen miktar; boşsa 0 (en az bir paket alınır),
|
||||||
|
* - ürün kalemi: paket adedi × paketin tip birimindeki karşılığı.
|
||||||
|
*/
|
||||||
|
export function requiredQuantity(
|
||||||
|
item: { kind: ShoppingItemKind; quantity: number | null },
|
||||||
|
inTypeUnit: number | null,
|
||||||
|
): number {
|
||||||
|
if (item.kind === "product") {
|
||||||
|
if (!inTypeUnit) return 0;
|
||||||
|
const packages = item.quantity ?? QUANTITY_SCALE;
|
||||||
|
return Math.round((packages * inTypeUnit) / QUANTITY_SCALE);
|
||||||
|
}
|
||||||
|
return item.quantity ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reddedilen eşik önerisi, reddedildikten sonra alım hareketi girilene (stok yenilenene) kadar
|
||||||
|
* gizli kalır; sonra ürün yine eşiğin altına düşerse öneri yeniden çıkar.
|
||||||
|
*/
|
||||||
|
export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date | null): boolean {
|
||||||
|
if (!dismissedAt) return false;
|
||||||
|
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user