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:
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { TypePriceComparison } from "@/features/prices/components/type-price-comparison";
|
||||
import { getTypePriceComparison } from "@/features/prices/service";
|
||||
import { ProductsSection } from "@/features/products/components/products-section";
|
||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||
@@ -13,24 +15,28 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
|
||||
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, options, products, types] = await Promise.all([
|
||||
const [data, options, products, types, prices] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||
listProductTypeOptions(user.id, household.id),
|
||||
getTypePriceComparison(user.id, household.id, id),
|
||||
]);
|
||||
if (!data || !options || !products || !types) notFound();
|
||||
if (!data || !options || !products || !types || !prices) notFound();
|
||||
return (
|
||||
<ProductDetailView
|
||||
data={data}
|
||||
categories={options.categories}
|
||||
products={
|
||||
<ProductsSection
|
||||
productTypeId={data.id}
|
||||
products={products.products}
|
||||
types={types}
|
||||
canWrite={products.canWrite}
|
||||
/>
|
||||
<>
|
||||
<TypePriceComparison data={prices} />
|
||||
<ProductsSection
|
||||
productTypeId={data.id}
|
||||
products={products.products}
|
||||
types={types}
|
||||
canWrite={products.canWrite}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductPriceView } from "@/features/prices/components/product-price-view";
|
||||
import { getProductPrices } from "@/features/prices/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.products.title };
|
||||
|
||||
/** /stok/urunler/:id — ürün (marka/paket): mağaza fiyatları, birim fiyat, fiyat geçmişi. */
|
||||
export default async function ProductPage({ params }: PageProps<"/stok/urunler/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getProductPrices(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return <ProductPriceView data={data} />;
|
||||
}
|
||||
@@ -31,6 +31,14 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-series-1: var(--series-1);
|
||||
--color-series-2: var(--series-2);
|
||||
--color-series-3: var(--series-3);
|
||||
--color-series-4: var(--series-4);
|
||||
--color-series-5: var(--series-5);
|
||||
--color-series-6: var(--series-6);
|
||||
--color-series-7: var(--series-7);
|
||||
--color-series-8: var(--series-8);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
@@ -95,6 +103,17 @@
|
||||
--chart-3: var(--expense);
|
||||
--chart-4: var(--warning);
|
||||
--chart-5: oklch(0.65 0.08 230);
|
||||
|
||||
/* Kategorik seriler (mağaza vb. kimlik; sabit sıra, döngü yok). dataviz doğrulayıcısından
|
||||
geçti (açık: kart #fff; 3 yuva 3:1 altında → lejant + tablo zorunlu). */
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
|
||||
@@ -156,6 +175,16 @@
|
||||
--chart-4: var(--warning);
|
||||
--chart-5: oklch(0.7 0.07 230);
|
||||
|
||||
/* Koyu yüzey (#101a2f) için aynı tonların koyu adımları; hepsi ≥ 3:1. */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
|
||||
--swatch-blue: oklch(0.74 0.13 258);
|
||||
--swatch-sky: oklch(0.74 0.13 230);
|
||||
--swatch-teal: oklch(0.74 0.13 185);
|
||||
|
||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 12.1;
|
||||
export const CURRENT_PHASE = 12.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export function PageHeader({
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,11 @@ export function ProductList({
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{p.label}</p>
|
||||
<p className="truncate font-medium">
|
||||
<Link href={`/stok/urunler/${p.id}`} className="hover:underline">
|
||||
{p.label}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="truncate text-sm text-muted-foreground tabular-nums">
|
||||
{p.packageLabel}
|
||||
{showType ? (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatUnitPrice } from "@/shared/prices";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
||||
|
||||
@@ -72,6 +74,7 @@ function Num({ value, unit, tone }: { value: number | null; unit: string; tone?:
|
||||
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
const groups = new Map<string, StockAnalysisRow[]>();
|
||||
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
||||
const totalCost = data.rows.reduce((sum, r) => sum + (r.avgMonthlyCostMinor ?? 0), 0);
|
||||
const first = data.months[0]!;
|
||||
const last = data.months.at(-1)!;
|
||||
|
||||
@@ -91,6 +94,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{totalCost > 0 ? <p className="text-sm">{t.totalCost(formatMoney(totalCost))}</p> : null}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
||||
@@ -106,10 +110,11 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(5,minmax(0,0.7fr))] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<span>{t.product}</span>
|
||||
<span>{t.trend(data.months.length)}</span>
|
||||
<span className="text-right">{t.avgUsed}</span>
|
||||
<span className="text-right">{t.avgCost}</span>
|
||||
<span className="text-right">{t.avgWasted}</span>
|
||||
<span className="text-right">{t.wastePct}</span>
|
||||
<span className="text-right">{t.thisMonth}</span>
|
||||
@@ -125,7 +130,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="grid grid-cols-2 items-center gap-x-3 gap-y-2 px-3 py-2.5 text-sm md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))]"
|
||||
className="grid grid-cols-2 items-center gap-x-3 gap-y-2 px-3 py-2.5 text-sm md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(5,minmax(0,0.7fr))]"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/${r.id}`}
|
||||
@@ -140,6 +145,29 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
</span>
|
||||
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgCost}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
r.avgMonthlyCostMinor === null && "text-muted-foreground",
|
||||
)}
|
||||
title={
|
||||
r.unitPrice
|
||||
? t.costHint(
|
||||
formatUnitPrice(r.unitPrice.minor, r.unit),
|
||||
formatDate(`${r.unitPrice.date}T12:00:00+03:00`),
|
||||
)
|
||||
: t.noPrice
|
||||
}
|
||||
>
|
||||
{r.avgMonthlyCostMinor === null
|
||||
? t.noData
|
||||
: formatMoney(r.avgMonthlyCostMinor)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgWasted}
|
||||
|
||||
Reference in New Issue
Block a user