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:
2026-11-06 12:27:51 +03:00
parent e67a1d5411
commit 9e32f2988d
11 changed files with 744 additions and 13 deletions
@@ -0,0 +1,124 @@
import { Clock, Tag, Trophy } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { formatUnitPrice, type RankedOffer } from "@/shared/prices";
import type { StoreOffer } from "../service";
const t = tr.productPrices;
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
/** Mağaza renk işareti (kimlik; metin her zaman yanında). */
export function StoreDot({ slot, className }: { slot: number; className?: string }) {
return (
<span
aria-hidden
className={cn("mr-1.5 inline-block size-2.5 shrink-0 rounded-full", className)}
style={{ backgroundColor: `var(--series-${slot})` }}
/>
);
}
/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */
function OfferBadges({ offer }: { offer: RankedOffer<StoreOffer> }) {
const p = offer.price;
return (
<span className="flex flex-wrap items-center gap-1">
{offer.cheapest ? (
<Badge className="gap-1 bg-income text-income-foreground">
<Trophy className="size-3" aria-hidden />
{t.cheapest}
</Badge>
) : null}
{p.isPromo && !offer.promoEnded ? (
<Badge variant="outline" className="gap-1 border-budget/50 text-budget">
<Tag className="size-3" aria-hidden />
{t.promo}
{p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""}
</Badge>
) : null}
{offer.fellBack ? <Badge variant="secondary">{t.fellBack}</Badge> : null}
{offer.promoEnded ? <Badge variant="secondary">{t.promoEnded}</Badge> : null}
{offer.stale ? (
<Badge
variant="outline"
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
title={t.staleHint}
>
<Clock className="size-3" aria-hidden />
{t.stale(offer.ageDays)}
</Badge>
) : null}
</span>
);
}
/**
* Birim fiyata göre sıralı teklifler. `showProduct`: ürün tipi karşılaştırmasında marka/paket
* ve ürün sayfası bağlantısı.
*/
export function OffersList({
offers,
typeUnit,
showProduct = false,
}: {
offers: RankedOffer<StoreOffer>[];
typeUnit: string;
showProduct?: boolean;
}) {
return (
<ol className="divide-y rounded-xl border bg-card">
{offers.map((o) => (
<li
key={o.key}
className={cn(
"grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 p-3",
o.cheapest && "bg-income/5",
)}
>
<span className="pt-0.5 text-sm text-muted-foreground tabular-nums">{o.rank ?? "–"}</span>
<div className="min-w-0">
{showProduct ? (
<p className="truncate font-medium">
<Link href={`/stok/urunler/${o.productId}`} className="hover:underline">
{o.productLabel}
</Link>{" "}
<span className="text-xs font-normal text-muted-foreground">{o.packageLabel}</span>
</p>
) : null}
<p
className={cn(
"flex flex-wrap items-center",
showProduct ? "text-sm text-muted-foreground" : "font-medium",
)}
>
<StoreDot slot={o.store.slot} />
{o.store.label}
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
· {ymdLabel(o.price.date)}
</span>
</p>
<div className="mt-1">
<OfferBadges offer={o} />
</div>
</div>
<div className="text-right tabular-nums">
<p className="font-semibold">
{o.unitPriceMinor === null ? (
<span className="text-sm font-normal text-muted-foreground">{t.noConversion}</span>
) : (
formatUnitPrice(o.unitPriceMinor, typeUnit)
)}
</p>
<p className="text-xs text-muted-foreground">
{formatMoney(o.price.priceMinor)}
{o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""}
</p>
</div>
</li>
))}
</ol>
);
}
@@ -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>
);
}
@@ -0,0 +1,271 @@
"use client";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { toast } from "sonner";
import { PageHeader } from "@/components/page-header";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
import { tr } from "@/i18n/tr";
import { formatMoney } from "@/shared/format";
import { formatUnitPrice } from "@/shared/prices";
import { deleteProductPriceAction } from "../actions";
import { priceProductOption } from "../options";
import type { PriceRow, ProductPriceDetail } from "../service";
import { OffersList, StoreDot, ymdLabel } from "./offers-list";
import { PriceFormDialog } from "./price-dialog";
const t = tr.productPrices;
const shortDate = (ymd: string) =>
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
new Date(`${ymd}T12:00:00+03:00`),
);
const compactTl = (minor: number) =>
new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 0 }).format(minor / 100) + " ₺";
/** Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli (basamaklı çizgi). */
function PriceChart({ data }: { data: ProductPriceDetail }) {
const config = Object.fromEntries(
data.series.map((s) => [`s${s.id}`, { label: s.label, color: `var(--series-${s.slot})` }]),
) satisfies ChartConfig;
return (
<ChartContainer
config={config}
className="aspect-auto h-64 w-full"
role="img"
aria-label={t.detail.chartLabel(data.product.label)}
>
<LineChart data={data.points} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
tickFormatter={(v: string) => shortDate(v)}
/>
<YAxis
tickLine={false}
axisLine={false}
width={64}
domain={["auto", "auto"]}
tickFormatter={(v: number) => compactTl(v)}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) =>
payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : ""
}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="flex items-center">
<StoreDot slot={data.series.find((s) => `s${s.id}` === name)?.slot ?? 1} />
{config[String(name)]?.label}
</span>
<span className="font-medium tabular-nums">{formatMoney(Number(value))}</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{data.series.map((s) => (
<Line
key={s.id}
dataKey={`s${s.id}`}
type="stepAfter"
stroke={`var(--color-s${s.id})`}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
);
}
/** /stok/urunler/:id — ürün bilgisi, mağazalardaki fiyatlar, fiyat geçmişi ve kayıtlar. */
export function ProductPriceView({ data }: { data: ProductPriceDetail }) {
const [adding, setAdding] = useState(false);
const [deleting, setDeleting] = useState<PriceRow | null>(null);
const p = data.product;
const canAdd = data.canWrite && !p.archived && data.stores.length > 0;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/stok/urunler">
<ArrowLeft />
{t.detail.back}
</Link>
</Button>
<PageHeader
title={p.label}
description={
<>
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
{t.detail.typeLink(p.productTypeName)}
</Link>
{" · "}
{p.packageLabel}
{p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""}
</>
}
actions={
canAdd ? (
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
{t.add}
</Button>
) : null
}
/>
<div className="-mt-2 flex flex-wrap items-center gap-2">
{p.archived ? <Badge variant="secondary">{tr.products.archived}</Badge> : null}
{p.conversionMissing ? (
<Badge
variant="outline"
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
>
{tr.products.conversionMissing}
</Badge>
) : null}
<AttachmentStrip items={p.images} />
</div>
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.detail.currentTitle}</CardTitle>
<CardDescription>{t.detail.currentDescription}</CardDescription>
</CardHeader>
<CardContent>
{data.offers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
) : (
<OffersList offers={data.offers} typeUnit={p.typeUnit} />
)}
</CardContent>
</Card>
{data.history.length > 1 ? (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.detail.chartTitle}</CardTitle>
<CardDescription>{t.detail.chartDescription}</CardDescription>
</CardHeader>
<CardContent>
<PriceChart data={data} />
</CardContent>
</Card>
) : null}
{data.history.length ? (
<section className="flex flex-col gap-2" aria-labelledby="price-history-title">
<h2 id="price-history-title" className="text-base font-medium">
{t.detail.historyTitle}
</h2>
<ul className="divide-y rounded-xl border bg-card">
{data.history.map((r) => (
<li key={r.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="flex items-center text-sm font-medium">
<StoreDot slot={r.store.slot} />
{r.store.label}
</p>
<p className="text-xs text-muted-foreground">
{ymdLabel(r.date)}
{r.isPromo
? ` · ${r.promoEnded ? t.promoEnded : t.promo}${
r.promoUntil ? ` (${t.promoUntil(ymdLabel(r.promoUntil))})` : ""
}`
: ""}
{` · ${t.sources[r.source.split(":")[0]!] ?? r.source}`}
{r.note ? ` · ${r.note}` : ""}
</p>
</div>
<div className="text-right text-sm tabular-nums">
<p className="font-medium">{formatMoney(r.priceMinor)}</p>
{r.unitPriceMinor !== null ? (
<p className="text-xs text-muted-foreground">
{formatUnitPrice(r.unitPriceMinor, p.typeUnit)}
</p>
) : null}
</div>
{data.canWrite ? (
<Button
size="icon-sm"
variant="ghost"
aria-label={t.detail.deletePrice}
onClick={() => setDeleting(r)}
>
<Trash2 />
</Button>
) : null}
</li>
))}
</ul>
</section>
) : null}
{adding ? (
<PriceFormDialog
products={[priceProductOption(p)]}
productId={p.id}
stores={data.stores}
onClose={() => setAdding(false)}
/>
) : null}
<AlertDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.detail.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.detail.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const r = deleting;
setDeleting(null);
if (!r) return;
const res = await deleteProductPriceAction(r.id);
if (res.ok) toast.success(t.toast.deleted);
else toast.error(res.error);
}}
>
{t.detail.deletePrice}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { unitPriceBase } from "@/shared/prices";
import type { TypePriceComparison as Data } from "../service";
import { OffersList } from "./offers-list";
import { PriceFormDialog } from "./price-dialog";
const t = tr.productPrices;
/** /stok/:id: markalar ve mağazalar birim fiyata göre; en uygun vurgulu. */
export function TypePriceComparison({ data }: { data: Data }) {
const [adding, setAdding] = useState(false);
return (
<Card size="sm">
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{t.comparisonTitle}</CardTitle>
<CardDescription>
{t.comparisonDescription(unitPriceBase(data.typeUnit).unit)}
</CardDescription>
</div>
{data.canWrite && data.products.length && data.stores.length ? (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus />
{t.add}
</Button>
) : null}
</CardHeader>
<CardContent>
{data.products.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noProducts}</p>
) : data.offers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
) : (
<OffersList offers={data.offers} typeUnit={data.typeUnit} showProduct />
)}
</CardContent>
{adding ? (
<PriceFormDialog
products={data.products}
stores={data.stores}
onClose={() => setAdding(false)}
/>
) : null}
</Card>
);
}