diff --git a/src/app/(app)/alisveris/[id]/mod/page.tsx b/src/app/(app)/alisveris/[id]/mod/page.tsx
new file mode 100644
index 0000000..4e4090f
--- /dev/null
+++ b/src/app/(app)/alisveris/[id]/mod/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(app)/alisveris/[id]/page.tsx b/src/app/(app)/alisveris/[id]/page.tsx
new file mode 100644
index 0000000..be80feb
--- /dev/null
+++ b/src/app/(app)/alisveris/[id]/page.tsx
@@ -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 ;
+}
diff --git a/src/app/(app)/alisveris/page.tsx b/src/app/(app)/alisveris/page.tsx
new file mode 100644
index 0000000..2729c5d
--- /dev/null
+++ b/src/app/(app)/alisveris/page.tsx
@@ -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 (
+ <>
+
+ {!data.canWrite ? (
+
{tr.shopping.readOnly}
+ ) : null}
+
+ >
+ );
+}
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index ee2af1c..2d29186 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
+import { ShoppingCard } from "@/features/shopping/components/shopping-card";
import { LowStockCard } from "@/features/stock/components/low-stock-card";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
@@ -106,9 +107,14 @@ export default async function DashboardPage() {
-
-
-
+
+
+
+
+
+
+
+
>
);
}
diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts
index f6c63ed..1a1e2a4 100644
--- a/src/components/layout/nav-config.ts
+++ b/src/components/layout/nav-config.ts
@@ -28,7 +28,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
-export const CURRENT_PHASE = 12.2;
+export const CURRENT_PHASE = 13.1;
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: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
- { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
+ { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
];
diff --git a/src/features/shopping/components/item-dialog.tsx b/src/features/shopping/components/item-dialog.tsx
new file mode 100644
index 0000000..742d3fc
--- /dev/null
+++ b/src/features/shopping/components/item-dialog.tsx
@@ -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;
+
+/** 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({});
+ const [busy, setBusy] = useState(false);
+ const set = (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();
+ 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 (
+ !o && onClose()}
+ title={item ? t.editItemTitle : t.addItemTitle}
+ description={item && item.kind !== "text" ? item.label : undefined}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/src/features/shopping/components/list-detail.tsx b/src/features/shopping/components/list-detail.tsx
new file mode 100644
index 0000000..f3c8bdb
--- /dev/null
+++ b/src/features/shopping/components/list-detail.tsx
@@ -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(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 (
+
+
+
+
+
+ {s.title}
+
+
{s.description}
+
+ {canWrite && suggestions.length > 1 ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+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 (
+
+
+ {item.checked ? (
+
+ ) : Icon ? (
+
+ ) : (
+ Aa
+ )}
+
+
+
+ {item.kind === "type" && item.productTypeId ? (
+
+ {item.label}
+
+ ) : item.kind === "product" && item.productId ? (
+
+ {item.label}
+
+ ) : (
+ item.label
+ )}
+
+ {item.detail || item.stock ? (
+
+ {item.stock ? t.stockLine(item.stock.current, item.stock.target) : item.detail}
+
+ ) : null}
+
+ {item.checked ? {tr.shopping.mode.inCart} : null}
+ {item.source === "suggestion" ? (
+
+
+ {t.fromSuggestion}
+
+ ) : null}
+ {item.archived ? {t.archivedRef} : null}
+ {item.note ? {item.note} : null}
+
+
+ {item.quantityLabel}
+ {canWrite ? (
+
+
+
+
+
+
+
+ {t.actions.edit}
+
+
+
+ {t.actions.moveTo}
+
+
+
+
+ {t.actions.remove}
+
+
+
+ ) : null}
+
+ );
+}
+
+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 (
+ !o && onClose()}
+ title={t.moveTitle}
+ description={t.moveDescription(item.label)}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+ {lists.length ? (
+
+ {t.moveTarget}
+
+
+ ) : (
+ {t.noOtherLists}
+ )}
+
+ );
+}
+
+/** /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(null);
+ const [moving, setMoving] = useState(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 (
+
+
+
+
+
+ {t.itemCount(list.itemCount)}
+ {list.checkedCount ? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}` : ""}
+ {list.note ? ` · ${list.note}` : ""}
+ >
+ }
+ />
+
+ {canWrite ? (
+
+
+
+
+
+ setEditingList(true)}>
+
+ {t.actions.editList}
+
+
+ setDeleting(true)}>
+
+ {t.actions.deleteList}
+
+
+
+ ) : null}
+
+
+
+ {canWrite ? (
+
+ ) : null}
+ {items.length && list.status === "active" ? (
+
+ ) : null}
+
+
+
+
+ {data.suggestions.length ? (
+
+ ) : null}
+ {items.length ? (
+
+ {items.map((i) => (
+ setEditing(i)}
+ onMove={() => setMoving(i)}
+ onRemove={() => remove(i)}
+ />
+ ))}
+
+ ) : (
+
+ {t.emptyList}
+
+ )}
+
+
+
+
+ {adding || editing ? (
+
{
+ setAdding(false);
+ setEditing(null);
+ }}
+ />
+ ) : null}
+ {moving ? (
+ setMoving(null)}
+ />
+ ) : null}
+ {editingList ? (
+ setEditingList(false)} />
+ ) : null}
+
+
+
+ {t.deleteTitle}
+ {t.deleteDescription(list.name)}
+
+
+ {tr.common.cancel}
+
+
+
+
+
+ );
+}
diff --git a/src/features/shopping/components/list-dialog.tsx b/src/features/shopping/components/list-dialog.tsx
new file mode 100644
index 0000000..a1d0049
--- /dev/null
+++ b/src/features/shopping/components/list-dialog.tsx
@@ -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>({});
+ 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 (
+ !o && onClose()}
+ title={list ? t.editTitle : t.createTitle}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/src/features/shopping/components/lists-view.tsx b/src/features/shopping/components/lists-view.tsx
new file mode 100644
index 0000000..68736c3
--- /dev/null
+++ b/src/features/shopping/components/lists-view.tsx
@@ -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 (
+
+
+
+
+
+
+
+
{list.name}
+
+ {t.itemCount(list.itemCount)}
+ {list.checkedCount && !done
+ ? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}`
+ : ""}
+
+
+
+ {list.preview.length ? (
+
+ {list.preview.join(", ")}
+ {list.itemCount > list.preview.length ? "…" : ""}
+
+ ) : (
+ {t.emptyList}
+ )}
+
+ {done && list.completedAt
+ ? t.completedAt(formatDate(list.completedAt))
+ : t.updated(formatDate(list.updatedAt))}
+
+
+
+ );
+}
+
+/** /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 (
+
+ {suggestions > 0 ? (
+
+
+ {t.suggestionsBanner(suggestions)}
+
+ ) : null}
+
+
+
{t.active}
+ {canWrite ? (
+
+ ) : null}
+
+ {active.length ? (
+
+ {active.map((l) => (
+
+ ))}
+
+ ) : (
+
+ {t.emptyActive}
+
+ )}
+
+ {completed.length ? (
+
+ {t.completed}
+
+ {completed.map((l) => (
+
+ ))}
+
+
+ ) : null}
+ {creating ?
setCreating(false)} /> : null}
+
+ );
+}
diff --git a/src/features/shopping/components/plan-panel.tsx b/src/features/shopping/components/plan-panel.tsx
new file mode 100644
index 0000000..67dcaf9
--- /dev/null
+++ b/src/features/shopping/components/plan-panel.tsx
@@ -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;
+}) {
+ return (
+
+ {option.stores.map((g) => {
+ const store = plan.stores[g.storeId];
+ return (
+
+
+
+
+ {store?.label ?? "—"}
+ {store?.kind === "online" ? (
+
+ ) : null}
+
+ {g.mealCardMinor > 0 ? (
+
+
+ {t.mealCard(formatMoney(g.mealCardMinor))}
+
+ ) : null}
+
+ {formatMoney(g.subtotalMinor + g.deliveryFeeMinor)}
+
+
+
+ {g.lines.map((l) => {
+ const p = plan.products[l.productId];
+ return (
+ -
+
+ {itemLabel.get(l.itemId) ?? "—"}
+
+ {p
+ ? p.label === itemLabel.get(l.itemId)
+ ? p.packageLabel
+ : `${p.label} · ${p.packageLabel}`
+ : "—"}
+ {p ? ` · ${formatUnitPrice(l.unitPriceMinor, p.typeUnit)}` : ""}
+
+
+
+ {formatMoney(l.costMinor)}
+ {l.packages > 1 ? (
+
+ {t.line(formatQuantity(l.packages * 1000), formatMoney(l.priceMinor))}
+
+ ) : null}
+
+
+ );
+ })}
+ {store?.kind === "online" ? (
+ -
+
+ {g.deliveryFeeMinor
+ ? t.delivery(formatMoney(g.deliveryFeeMinor))
+ : t.freeDelivery}
+
+
+ {g.deliveryFeeMinor ? formatMoney(g.deliveryFeeMinor) : ""}
+
+
+ ) : null}
+
+
+ );
+ })}
+ {option.missing.length ? (
+
+ {t.missing}: {option.missing.map((id) => itemLabel.get(id) ?? "—").join(", ")}
+
+ ) : null}
+
+ {t.total}
+ {formatMoney(option.totalMinor)}
+
+
+ );
+}
+
+/** 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("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 (
+
+
+
+ {t.title}
+
+
{t.description}
+
+ {!option ? (
+ {t.empty}
+ ) : (
+ <>
+ {headline ? {headline}
: null}
+ {plan.single && plan.single.missing.length && differs ? (
+
+ {t.singleMissing(plan.single.missing.length)}
+
+ ) : null}
+ {differs ? (
+ setChoice(x as PlanChoice)}>
+
+ {t.recommended}
+ {t.single}
+
+
+ ) : null}
+
+ >
+ )}
+ {plan.unpriced.length ? (
+
+ {t.unpriced}:{" "}
+
+ {plan.unpriced.map((id) => itemLabel.get(id) ?? "—").join(", ")}
+
+ {t.unpricedHint}
+
+ ) : null}
+
+ -
+
+ {t.threshold(formatMoney(plan.thresholdMinor))}
+
+ {plan.mealCardBalanceMinor > 0 ? (
+ -
+
+ {t.mealCardBalance(formatMoney(plan.mealCardBalanceMinor))}
+
+ ) : null}
+ {hasText ? (
+ -
+
+ {t.textItems}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/features/shopping/components/shopping-card.tsx b/src/features/shopping/components/shopping-card.tsx
new file mode 100644
index 0000000..72316be
--- /dev/null
+++ b/src/features/shopping/components/shopping-card.tsx
@@ -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 (
+
+
+
+
+ {t.dashboard.title}
+
+ {data.suggestions ? (
+
+
+ {t.dashboard.suggestions(data.suggestions)}
+
+ ) : null}
+
+
+ {data.lists.length ? (
+
+ {data.lists.slice(0, LIMIT).map((l) => (
+ -
+
+ {l.name}
+
+
+ {l.checkedCount
+ ? t.checkedCount(l.checkedCount, l.itemCount)
+ : t.itemCount(l.itemCount)}
+
+
+ ))}
+
+ ) : (
+ {t.dashboard.empty}
+ )}
+
+ {t.dashboard.open}
+
+
+
+ );
+}
diff --git a/src/features/shopping/components/shopping-mode.tsx b/src/features/shopping/components/shopping-mode.tsx
new file mode 100644
index 0000000..7ec8827
--- /dev/null
+++ b/src/features/shopping/components/shopping-mode.tsx
@@ -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 (
+
+
+
+ );
+}
+
+/** /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();
+ 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 (
+
+
+
+
+ {m.title}: {list.name}
+
+ {headline ?
{headline}
: null}
+ {differs ? (
+
+ ) : null}
+
+
+
+
+ {m.progress(done, items.length)}
+ {done === items.length && items.length ? (
+ {m.allDone}
+ ) : null}
+
+
+
+
+ {groups.map((g) => {
+ const groupDone = g.rows.filter((r) => r.item.checked).length;
+ return (
+
+
+
+ {g.slot ? : null}
+ {g.title}
+ {g.online ? (
+
+ ) : null}
+
+
+ {groupDone}/{g.rows.length}
+
+
+ {g.hint ? {g.hint}
: null}
+
+ {g.rows.map(({ item, line }) => (
+ toggle(item)}
+ />
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/features/stock/components/stock-dialogs.tsx b/src/features/stock/components/stock-dialogs.tsx
index 32b78bc..c225b0b 100644
--- a/src/features/stock/components/stock-dialogs.tsx
+++ b/src/features/stock/components/stock-dialogs.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
@@ -73,6 +74,7 @@ export function ProductTypeFormDialog({
step: item ? quantityInput(item.step) : "1",
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
note: item?.note ?? "",
+ bulky: item?.bulky ?? false,
initialQuantity: "",
});
const [errors, setErrors] = useState({});
@@ -93,6 +95,7 @@ export function ProductTypeFormDialog({
step: v.step,
countEveryDays: v.countEveryDays,
note: v.note,
+ bulky: v.bulky,
initialQuantity: item ? "" : v.initialQuantity,
});
setBusy(false);
@@ -285,6 +288,17 @@ export function ProductTypeFormDialog({
{f.stepHint} {f.countEveryDaysHint}
+
+ set("bulky", x === true)}
+ />
+
+ {f.bulky}
+ {f.bulkyHint}
+
+
{f.note}
set("note", e.target.value)} />
diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx
index db6a611..f25b1e5 100644
--- a/src/features/stock/components/stock-view.tsx
+++ b/src/features/stock/components/stock-view.tsx
@@ -216,7 +216,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
{t.suggestions.title}
{t.suggestions.description}
-
+
+ {data.suggestions.length ? (
+
+ {t.suggestions.toShopping}
+
+ ) : null}
{data.suggestions.length === 0 ? (
{t.suggestions.empty}
) : (
diff --git a/src/hooks/use-auto-refresh.ts b/src/hooks/use-auto-refresh.ts
new file mode 100644
index 0000000..7eeb295
--- /dev/null
+++ b/src/hooks/use-auto-refresh.ts
@@ -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]);
+}