+
+
+
+ {t.title}: {sheet.list.name}
+
+
{t.description}
+
+ {sheet.stores.length === 0 ? (
+
{t.noStores}
+ ) : null}
+
+ {t.date}
+ setDate(e.target.value)}
+ />
+ {errors.date ? {errors.date} : null}
+
+
+
+
+ {t.itemsTitle}
+
+
+ {sheet.items.map((item) => {
+ const row = rows[item.id]!;
+ const plan = sheet.planned[item.id];
+ const planProduct = plan ? sheet.products.find((p) => p.id === plan.productId) : null;
+ const planStore = plan ? sheet.stores.find((s) => s.id === plan.storeId) : null;
+ const substitute =
+ row.bought &&
+ item.kind !== "text" &&
+ (item.kind === "product"
+ ? row.productId !== item.productId
+ : plan && row.productId !== plan.productId);
+ const key = `item:${item.id}`;
+ return (
+ -
+
+ setRow(item.id, { bought: x === true })}
+ className="mt-0.5 size-5"
+ />
+
+ {substitute ? {t.substitute} : null}
+
+ {row.bought ? (
+ setRow(item.id, patch)}
+ errors={errs(key)}
+ idPrefix={`l-${item.id}`}
+ />
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+ {extras.length ? (
+
+ {extras.map((e) => (
+ -
+
+
+ setExtras((s) =>
+ s.map((x) =>
+ x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x,
+ ),
+ )
+ }
+ errors={errs(`extra:${e.key}`)}
+ idPrefix={`e-${e.key}`}
+ />
+
+
+
+ ))}
+
+ ) : null}
+
+
+
+ {storeIds.length ? (
+
+
+
+ {t.storesTitle}
+
+
{t.storesDescription}
+
+
+
+ ) : null}
+
+ {needsFallback ? (
+
+ {t.fallbackCategory}
+
+ {t.fallbackHint}
+ {errors.fallbackCategoryId ? {errors.fallbackCategoryId} : null}
+
+ ) : null}
+
+ {unbought.length ? (
+
+ ) : null}
+
+
+
+
+
+ {t.total}: {formatMoney(total)}
+
+ {plannedTotal ? (
+
+ {" · "}
+ {t.plannedTotal}: {formatMoney(plannedTotal)}
+
+ ) : null}
+ {errors.root ? (
+ {errors.root}
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/features/shopping/components/list-detail.tsx b/src/features/shopping/components/list-detail.tsx
index f3c8bdb..f6bbcb7 100644
--- a/src/features/shopping/components/list-detail.tsx
+++ b/src/features/shopping/components/list-detail.tsx
@@ -4,6 +4,7 @@ import {
ArrowLeft,
ArrowRightLeft,
Check,
+ ClipboardCheck,
EllipsisVertical,
Package,
Pencil,
@@ -62,6 +63,7 @@ import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../se
import { ShoppingItemDialog } from "./item-dialog";
import { ShoppingListDialog } from "./list-dialog";
import { PlanPanel } from "./plan-panel";
+import { ShoppingReport } from "./report-view";
const t = tr.shopping;
@@ -411,34 +413,50 @@ export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) {
) : null}
+ {items.length && canWrite ? (
+
-
- {data.suggestions.length ? (
-
- ) : null}
- {items.length ? (
-
- {items.map((i) => (
- setEditing(i)}
- onMove={() => setMoving(i)}
- onRemove={() => remove(i)}
- />
- ))}
-
- ) : (
-
- {t.emptyList}
-
- )}
+ {data.report ? (
+
+ ) : (
+
+
+ {data.suggestions.length ? (
+
+ ) : null}
+ {items.length ? (
+
+ {items.map((i) => (
+ setEditing(i)}
+ onMove={() => setMoving(i)}
+ onRemove={() => remove(i)}
+ />
+ ))}
+
+ ) : (
+
+ {t.emptyList}
+
+ )}
+
+
-
-
+ )}
{adding || editing ? (
= {
+ same: "border-income/40 text-income",
+ substitute: "border-amber-500/50 text-amber-700 dark:text-amber-400",
+ skipped: "text-muted-foreground",
+ extra: "border-budget/40 text-budget",
+};
+
+/** İşaretli tutar: "+₺12,00" / "−₺5,00". Renk tek başına anlam taşımaz. */
+function Signed({ value, className }: { value: number; className?: string }) {
+ return (
+ 0 ? "text-expense" : value < 0 ? "text-income" : "text-muted-foreground",
+ className,
+ )}
+ >
+ {value > 0 ? "+" : value < 0 ? "−" : ""}
+ {formatMoney(Math.abs(value))}
+
+ );
+}
+
+function Cell({ label, sub, amount }: { label: string; sub: string; amount: number | null }) {
+ return (
+
+ {label || "—"}
+ {sub ? {sub} : null}
+ {amount !== null ? {formatMoney(amount)} : null}
+
+ );
+}
+
+const pkgs = (packages: number) => `${formatQuantity(packages)} ${tr.shopping.packageUnit}`;
+
+function Row({ row }: { row: CompletionRow }) {
+ return (
+
+ {row.label}
+
+
+ {t.matches[row.match]}
+
+
+
+ {t.plan}:
+ {row.plan ? (
+ |
+ ) : (
+ {t.noPlanCell}
+ )}
+
+
+ {t.bought}:
+ {row.amountMinor !== null ? (
+ |
+ ) : (
+ {t.noPlanCell}
+ )}
+
+
+ );
+}
+
+/** Tamamlanan liste: planlanan vs. gerçek, farkın nedenleri, kalem bazında eşleşme, giderler. */
+export function ShoppingReport({
+ completion,
+ summary,
+}: {
+ completion: ShoppingCompletion;
+ summary: ComparisonSummary;
+}) {
+ const reasons = (Object.entries(summary.reasons) as [keyof ComparisonReasons, number][]).filter(
+ ([, v]) => v !== 0,
+ );
+ return (
+
+
+
+ {t.title}
+
+
+ {t.description(formatDate(`${completion.date}T12:00:00+03:00`))}
+
+
+
+ {[
+ { label: t.planned, node: formatMoney(summary.plannedMinor) },
+ { label: t.actual, node: formatMoney(summary.actualMinor) },
+ { label: t.diff, node: },
+ ].map((x) => (
+
+
- {x.label}
+ - {x.node}
+
+ ))}
+
+ {reasons.length ? (
+
+
{t.reasonsTitle}
+
+ {reasons.map(([k, v]) => (
+ -
+ {t.reasons[k]}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ {t.item}
+
+ {t.plan}
+ {t.bought}
+
+
+ {completion.rows.map((r, i) => (
+
+ ))}
+
+
+
+
+
{t.expenses}
+
+ {completion.stores.map((s) => (
+ -
+
+
+ {s.label}
+
+ {t.expenseLine(s.accountName, s.attachmentCount)}
+
+
+
+ {formatMoney(s.amountMinor)}
+
+
+ ))}
+
+
+ {completion.movedTo ? (
+
+ {t.movedTo(completion.movedTo.name)}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/shopping/components/shopping-mode.tsx b/src/features/shopping/components/shopping-mode.tsx
index 7ec8827..a3da0d9 100644
--- a/src/features/shopping/components/shopping-mode.tsx
+++ b/src/features/shopping/components/shopping-mode.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ArrowLeft, Check, Globe } from "lucide-react";
+import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react";
import Link from "next/link";
import { startTransition, useOptimistic } from "react";
import { toast } from "sonner";
@@ -244,6 +244,14 @@ export function ShoppingModeView({
);
})}
+ {canWrite && done > 0 ? (
+
+ ) : null}
);
}
diff --git a/src/features/shopping/service.ts b/src/features/shopping/service.ts
index 9e85834..a3b4897 100644
--- a/src/features/shopping/service.ts
+++ b/src/features/shopping/service.ts
@@ -29,8 +29,10 @@ import {
suggestionHidden,
type ShoppingItemKind,
type ShoppingItemSource,
+ type ShoppingCompletion,
type ShoppingListStatus,
} from "@/shared/shopping";
+import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
import { compareTr } from "@/shared/search";
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
@@ -44,7 +46,7 @@ import type { StoreKind } from "@/shared/stores";
const t = tr.shopping;
-type ItemLean = {
+export type ItemLean = {
_id: Types.ObjectId;
kind: ShoppingItemKind;
productTypeId: Types.ObjectId | null;
@@ -59,13 +61,14 @@ type ItemLean = {
addedAt: Date;
};
-type ListLean = {
+export type ListLean = {
_id: Types.ObjectId;
name: string;
status: ShoppingListStatus;
note: string;
items: ItemLean[];
completedAt: Date | null;
+ completion: ShoppingCompletion | null;
createdAt: Date;
updatedAt: Date;
};
@@ -154,6 +157,8 @@ export type ShoppingListDetail = {
/** Kalem taşımak için diğer aktif listeler. */
otherLists: { id: string; name: string }[];
canWrite: boolean;
+ /** Tamamlanmış listede karşılaştırma raporu. */
+ report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null;
};
// --- Yardımcılar ---
@@ -401,7 +406,7 @@ function itemView(
};
}
-async function buildPlan(
+export async function buildPlan(
userId: string,
m: Membership,
items: ItemLean[],
@@ -576,12 +581,27 @@ export async function getShoppingList(
},
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
canWrite,
+ report: list.completion
+ ? {
+ completion: list.completion,
+ summary: compareShopping(
+ list.completion.rows.map((r) => ({
+ match: r.match,
+ plan: r.plan,
+ actual:
+ r.amountMinor === null
+ ? null
+ : { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
+ })),
+ ),
+ }
+ : null,
};
}
// --- Yazma: listeler ---
-async function writableList(actorId: string, householdId: string, listId: string) {
+export 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);
diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts
index 563b498..4a36f6d 100644
--- a/src/i18n/tr.ts
+++ b/src/i18n/tr.ts
@@ -1491,6 +1491,95 @@ export const tr = {
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ş).",
+ amount: "Geçerli bir tutar girin (ör. 89,90)",
+ noLines: "Alınan en az bir ürün seçin",
+ duplicateLine: "Bir liste kalemi yalnızca bir kez girilebilir",
+ duplicateStore: "Mağaza iki kez girilmiş",
+ account: "Her mağaza için ödeme hesabı seçin",
+ targetList: "Alınmayanlar için hedef liste seçin",
+ futureDate: "Gelecek tarihli alışveriş girilemez",
+ itemGone: "Listede olmayan bir kalem seçilmiş; sayfayı yenileyin",
+ substituteType: "Muadil aynı ürün tipinden olmalı",
+ textNoProduct: "Serbest metin kalemine ürün seçilemez",
+ conversion: (name: string) => `"${name}" ürününün tip birimine dönüşümü yok`,
+ fallbackCategory: "Eşlenmemiş kalemler için gider kategorisi seçin",
+ tooManySplits: "Bir mağazada çok fazla gider kategorisi var",
+ },
+ complete: {
+ action: "Alışverişi tamamla",
+ title: "Alışverişi tamamla",
+ description:
+ "Aldıklarınızı seçin: aynı ürün ya da muadili, paket adedi, ödenen tutar ve mağaza. Kaydedince stok, fiyatlar, mağaza başına gider ve fişler tek seferde işlenir.",
+ date: "Alışveriş tarihi",
+ bought: "Alındı",
+ itemsTitle: "Listedekiler",
+ extrasTitle: "Listede olmayanlar",
+ addExtra: "Listede olmayan ürün ekle",
+ removeExtra: "Ekstrayı çıkar",
+ product: "Alınan ürün",
+ store: "Mağaza",
+ packages: "Paket",
+ quantity: "Miktar",
+ generic: (unit: string) => `Marka belirtmeden (${unit})`,
+ amount: "Tutar (₺)",
+ amountHint: "Fişteki satır tutarı.",
+ planned: (text: string) => `Plan: ${text}`,
+ substitute: "Muadil",
+ noPlan: "Planda fiyat yoktu",
+ textItem: "Serbest metin: stok ve fiyata işlenmez.",
+ storesTitle: "Ödeme ve fiş",
+ storesDescription:
+ "Her mağaza ayrı gider işlemi olur; kalemler stok kategorisinin gider kategorisine bölünür.",
+ account: "Ödeme hesabı",
+ receipts: "Fiş görselleri",
+ addReceipt: "Fiş ekle",
+ receiptHint: "Fiş fotoğrafı; işlemin ekine kaydedilir.",
+ subtotal: (n: number) => `${n} kalem`,
+ fallbackCategory: "Eşlenmemiş kalemlerin gider kategorisi",
+ fallbackHint:
+ "Stok kategorisi bir gider kategorisine eşlenmemiş ürünler ve serbest metin kalemleri buraya yazılır.",
+ leftoverTitle: "Alınmayanlar",
+ leftoverDescription: (n: number) => `${n} kalem alınmadı.`,
+ leftoverActions: {
+ move: "Başka aktif listeye taşı",
+ new: "Yeni liste aç",
+ drop: "Bırak (listeyle kapanır)",
+ },
+ newListName: "Yeni liste adı",
+ newListDefault: (name: string) => `${name} – kalanlar`,
+ total: "Toplam",
+ plannedTotal: "Planlanan",
+ submit: "Alışverişi tamamla",
+ saved: "Alışveriş tamamlandı; stok, fiyatlar ve giderler işlendi.",
+ txDescription: (list: string) => `Alışveriş: ${list}`,
+ splitNote: (n: number) => `${n} kalem`,
+ noStores: "Mağaza yok. Önce bir mağaza ekleyin.",
+ },
+ report: {
+ title: "Karşılaştırma raporu",
+ description: (date: string) =>
+ `Alışveriş tarihi ${date}. Plan, tamamlama anındaki akıllı öneridir.`,
+ planned: "Planlanan",
+ actual: "Gerçekleşen",
+ diff: "Fark",
+ reasonsTitle: "Farkın nedenleri",
+ reasons: {
+ price: "Fiyat farkı (aynı ürün)",
+ quantity: "Miktar farkı",
+ substitute: "Muadil ürün",
+ skipped: "Alınmayanlar",
+ extra: "Plansız ekstralar",
+ unplanned: "Planda fiyatı olmayanlar",
+ },
+ matches: { same: "Aynı", substitute: "Muadil", skipped: "Alınmadı", extra: "Plansız ekstra" },
+ item: "Kalem",
+ plan: "Plan",
+ bought: "Alınan",
+ expenses: "Mağaza giderleri",
+ expenseLine: (account: string, receipts: number) =>
+ receipts ? `${account} · ${receipts} fiş` : account,
+ movedTo: (name: string) => `Alınmayanlar "${name}" listesine taşındı.`,
+ noPlanCell: "—",
},
dashboard: {
title: "Alışveriş",
diff --git a/src/models/shopping-list.ts b/src/models/shopping-list.ts
index d842d30..214897f 100644
--- a/src/models/shopping-list.ts
+++ b/src/models/shopping-list.ts
@@ -40,6 +40,8 @@ const shoppingListSchema = new Schema(
note: { type: String, default: "", trim: true, maxlength: 300 },
items: { type: [shoppingItemSchema], default: [] },
completedAt: { type: Date, default: null },
+ /** Tamamlama anındaki karşılaştırma görüntüsü (ShoppingCompletion; etiketler sabitlenir). */
+ completion: { type: Schema.Types.Mixed, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
diff --git a/src/models/stock-movement.ts b/src/models/stock-movement.ts
index 01ce86f..c4fc058 100644
--- a/src/models/stock-movement.ts
+++ b/src/models/stock-movement.ts
@@ -21,6 +21,8 @@ const stockMovementSchema = new Schema(
initial: { type: Boolean, default: false },
/** Sayım oturumu (Faz 11b). */
countId: { type: Schema.Types.ObjectId, default: null },
+ /** Alışverişi tamamlamadan gelen alım (Faz 13b). */
+ shoppingListId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
diff --git a/src/models/transaction.ts b/src/models/transaction.ts
index 21a2efc..18cdd8d 100644
--- a/src/models/transaction.ts
+++ b/src/models/transaction.ts
@@ -76,6 +76,8 @@ const transactionSchema = new Schema(
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
isAdjustment: { type: Boolean, default: false },
+ /** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
+ shoppingListId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
seedRef: { type: String, default: null },
diff --git a/src/shared/schemas/shopping.ts b/src/shared/schemas/shopping.ts
index d0ca59f..868f28d 100644
--- a/src/shared/schemas/shopping.ts
+++ b/src/shared/schemas/shopping.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
+import { parseMoney } from "../money";
import { parseQuantity } from "../quantity";
const s = tr.shopping.validation;
@@ -72,3 +73,91 @@ export const shoppingItemUpdateSchema = z.object({
export type ShoppingItemUpdate = z.output