feat(ui): ürün sayfası, fiyat geçmişi grafiği ve birim fiyat karşılaştırması
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parseMoney } from "@/shared/money";
|
||||
import { formatUnitPrice, unitPrice } from "@/shared/prices";
|
||||
import { addProductPriceAction } from "../actions";
|
||||
import type { PriceProductOption } from "../options";
|
||||
import type { StoreRef } from "../service";
|
||||
import { StoreDot } from "./offers-list";
|
||||
|
||||
const t = tr.productPrices;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Elle fiyat girişi: ürün (sabit olabilir), mağaza, tarih, paket fiyatı, kampanya. */
|
||||
export function PriceFormDialog({
|
||||
products,
|
||||
stores,
|
||||
productId,
|
||||
onClose,
|
||||
}: {
|
||||
products: PriceProductOption[];
|
||||
stores: StoreRef[];
|
||||
/** Ürün sayfasından açılınca sabit. */
|
||||
productId?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
productId: productId ?? (products.length === 1 ? products[0]!.id : ""),
|
||||
storeId: "",
|
||||
date: todayIstanbul(),
|
||||
price: "",
|
||||
isPromo: false,
|
||||
promoUntil: "",
|
||||
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 product = products.find((p) => p.id === v.productId);
|
||||
const minor = parseMoney(v.price);
|
||||
const preview =
|
||||
product?.inTypeUnit && minor && minor > 0
|
||||
? formatUnitPrice(unitPrice(minor, product.inTypeUnit), product.typeUnit)
|
||||
: null;
|
||||
const f = t.fields;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addProductPriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.addTitle}
|
||||
description={product ? `${product.label} · ${product.packageLabel}` : undefined}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
{productId ? null : (
|
||||
<Field data-invalid={Boolean(errors.productId)}>
|
||||
<FieldLabel htmlFor="pp-product">{f.product}</FieldLabel>
|
||||
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||
<SelectTrigger
|
||||
id="pp-product"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.product} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.label}{" "}
|
||||
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field data-invalid={Boolean(errors.storeId)}>
|
||||
<FieldLabel htmlFor="pp-store">{f.store}</FieldLabel>
|
||||
<Select value={v.storeId} onValueChange={(x) => set("storeId", x)}>
|
||||
<SelectTrigger
|
||||
id="pp-store"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.storeId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.store} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stores.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<StoreDot slot={s.slot} />
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.storeId ? <FieldError>{errors.storeId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pp-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="pp-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
aria-invalid={Boolean(errors.date)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.price)}>
|
||||
<FieldLabel htmlFor="pp-price">{f.price}</FieldLabel>
|
||||
<Input
|
||||
id="pp-price"
|
||||
inputMode="decimal"
|
||||
value={v.price}
|
||||
onChange={(e) => set("price", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.price)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.price ? <FieldError>{errors.price}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{preview ? (
|
||||
<FieldDescription className="-mt-2 text-right tabular-nums">
|
||||
{f.unitPreview(preview)}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="pp-promo"
|
||||
checked={v.isPromo}
|
||||
onCheckedChange={(x) => set("isPromo", x === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="pp-promo">{f.isPromo}</FieldLabel>
|
||||
</Field>
|
||||
{v.isPromo ? (
|
||||
<Field data-invalid={Boolean(errors.promoUntil)}>
|
||||
<FieldLabel htmlFor="pp-until">{f.promoUntil}</FieldLabel>
|
||||
<Input
|
||||
id="pp-until"
|
||||
type="date"
|
||||
value={v.promoUntil}
|
||||
min={v.date}
|
||||
onChange={(e) => set("promoUntil", e.target.value)}
|
||||
aria-invalid={Boolean(errors.promoUntil)}
|
||||
/>
|
||||
<FieldDescription>{f.promoUntilHint}</FieldDescription>
|
||||
{errors.promoUntil ? <FieldError>{errors.promoUntil}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pp-note">{f.note}</FieldLabel>
|
||||
<Input id="pp-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user