feat(ui): alışveriş listeleri, akıllı öneri paneli ve alışveriş modu
- /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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user