From fa70168622031442ce0c37e183fb9b1d5d2f1ebc Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 23:28:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20al=C4=B1=C5=9Fveri=C5=9F=20listeler?= =?UTF-8?q?i,=20ak=C4=B1ll=C4=B1=20=C3=B6neri=20paneli=20ve=20al=C4=B1?= =?UTF-8?q?=C5=9Fveri=C5=9F=20modu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /alisveris, /alisveris/:id ve /alisveris/:id/mod sayfaları; odak ve 30 sn yenileme - stok önerileri paneli, kalem ekle/düzenle/taşı, mağazaya gruplu kontrol listesi - dashboard Alışveriş kartı, kenar menü, ürün tipi formunda hacimli işareti Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/alisveris/[id]/mod/page.tsx | 22 + src/app/(app)/alisveris/[id]/page.tsx | 17 + src/app/(app)/alisveris/page.tsx | 30 ++ src/app/(app)/dashboard/page.tsx | 12 +- src/components/layout/nav-config.ts | 4 +- .../shopping/components/item-dialog.tsx | 222 ++++++++ .../shopping/components/list-detail.tsx | 491 ++++++++++++++++++ .../shopping/components/list-dialog.tsx | 86 +++ .../shopping/components/lists-view.tsx | 115 ++++ .../shopping/components/plan-panel.tsx | 194 +++++++ .../shopping/components/shopping-card.tsx | 59 +++ .../shopping/components/shopping-mode.tsx | 249 +++++++++ .../stock/components/stock-dialogs.tsx | 14 + src/features/stock/components/stock-view.tsx | 7 +- src/hooks/use-auto-refresh.ts | 25 + 15 files changed, 1541 insertions(+), 6 deletions(-) create mode 100644 src/app/(app)/alisveris/[id]/mod/page.tsx create mode 100644 src/app/(app)/alisveris/[id]/page.tsx create mode 100644 src/app/(app)/alisveris/page.tsx create mode 100644 src/features/shopping/components/item-dialog.tsx create mode 100644 src/features/shopping/components/list-detail.tsx create mode 100644 src/features/shopping/components/list-dialog.tsx create mode 100644 src/features/shopping/components/lists-view.tsx create mode 100644 src/features/shopping/components/plan-panel.tsx create mode 100644 src/features/shopping/components/shopping-card.tsx create mode 100644 src/features/shopping/components/shopping-mode.tsx create mode 100644 src/hooks/use-auto-refresh.ts 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={ + <> + + + + } + > +
+ + {item ? null : ( + + {f.kind} + set("kind", x as ShoppingItemKind)}> + + {SHOPPING_ITEM_KINDS.map((k) => ( + + {t.kinds[k]} + + ))} + + + {t.kindHints[v.kind]} + + )} + {!item && v.kind === "type" ? ( + + {f.productType} + + {errors.productTypeId ? {errors.productTypeId} : null} + + ) : null} + {!item && v.kind === "product" ? ( + + {f.product} + + {errors.productId ? {errors.productId} : null} + + ) : null} + {v.kind === "text" ? ( + + {f.text} + set("text", e.target.value)} + aria-invalid={Boolean(errors.text)} + autoFocus={!item} + /> + {errors.text ? {errors.text} : null} + + ) : null} +
+ + + {v.kind === "product" ? f.packages : f.quantity} + {unitSuffix ? ` (${unitSuffix})` : ""} + + set("quantity", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.quantity)} + /> + {errors.quantity ? {errors.quantity} : null} + + {v.kind === "text" ? ( + + {f.unit} + set("unit", e.target.value)} + /> + + ) : null} +
+ {v.kind !== "text" ? ( + {f.quantityHint} + ) : null} + + {f.itemNote} + set("note", e.target.value)} + /> + + {errors.root ? {errors.root} : null} +
+
+
+ ); +} 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} +
+
    + {suggestions.map((x) => { + const reasons = [ + x.suggestion.waste + ? tr.stock.suggestions.wasteReduced( + x.suggestion.waste.purchases, + x.suggestion.waste.pct, + ) + : null, + ].filter(Boolean); + return ( +
  • +
    +

    + + {x.name} + +

    +

    + {s.line( + formatStock(Math.max(0, x.current), x.unit), + formatStock(x.target, x.unit), + x.pct, + )} +

    +

    + {s.suggested(formatStock(x.suggestedQuantity, x.unit))} + {reasons.length ? ( + · {reasons.join(" · ")} + ) : null} +

    +
    + {canWrite ? ( +
    + + +
    + ) : 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={ + <> + + + + } + > +
    + + + {t.fields.name} + setV((s) => ({ ...s, name: e.target.value }))} + aria-invalid={Boolean(errors.name)} + autoFocus + /> + {errors.name ? {errors.name} : null} + + + {t.fields.note} + setV((s) => ({ ...s, note: e.target.value }))} + /> + + {errors.root ? {errors.root} : null} + +
    +
    + ); +} 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]); +}