9e32f2988d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|