diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx index 2b6e2ae..65e9f4a 100644 --- a/src/app/(app)/stok/[id]/page.tsx +++ b/src/app/(app)/stok/[id]/page.tsx @@ -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 ( + <> + + + } /> ); diff --git a/src/app/(app)/stok/urunler/[id]/page.tsx b/src/app/(app)/stok/urunler/[id]/page.tsx new file mode 100644 index 0000000..6dcab9f --- /dev/null +++ b/src/app/(app)/stok/urunler/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/globals.css b/src/app/globals.css index da536eb..31f01e6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 32810ed..f6c63ed 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -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; diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx index c3fe65d..c1b74ec 100644 --- a/src/components/page-header.tsx +++ b/src/components/page-header.tsx @@ -5,7 +5,7 @@ export function PageHeader({ actions, }: { title: string; - description?: string; + description?: React.ReactNode; actions?: React.ReactNode; }) { return ( diff --git a/src/features/prices/components/offers-list.tsx b/src/features/prices/components/offers-list.tsx new file mode 100644 index 0000000..4d4ea71 --- /dev/null +++ b/src/features/prices/components/offers-list.tsx @@ -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 ( + + ); +} + +/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */ +function OfferBadges({ offer }: { offer: RankedOffer }) { + const p = offer.price; + return ( + + {offer.cheapest ? ( + + + {t.cheapest} + + ) : null} + {p.isPromo && !offer.promoEnded ? ( + + + {t.promo} + {p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""} + + ) : null} + {offer.fellBack ? {t.fellBack} : null} + {offer.promoEnded ? {t.promoEnded} : null} + {offer.stale ? ( + + + {t.stale(offer.ageDays)} + + ) : null} + + ); +} + +/** + * 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[]; + typeUnit: string; + showProduct?: boolean; +}) { + return ( +
    + {offers.map((o) => ( +
  1. + {o.rank ?? "–"} +
    + {showProduct ? ( +

    + + {o.productLabel} + {" "} + {o.packageLabel} +

    + ) : null} +

    + + {o.store.label} + + · {ymdLabel(o.price.date)} + +

    +
    + +
    +
    +
    +

    + {o.unitPriceMinor === null ? ( + {t.noConversion} + ) : ( + formatUnitPrice(o.unitPriceMinor, typeUnit) + )} +

    +

    + {formatMoney(o.price.priceMinor)} + {o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""} +

    +
    +
  2. + ))} +
+ ); +} diff --git a/src/features/prices/components/price-dialog.tsx b/src/features/prices/components/price-dialog.tsx new file mode 100644 index 0000000..3446765 --- /dev/null +++ b/src/features/prices/components/price-dialog.tsx @@ -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; + +/** 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({}); + const [busy, setBusy] = useState(false); + const set = (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 ( + !o && onClose()} + title={t.addTitle} + description={product ? `${product.label} · ${product.packageLabel}` : undefined} + footer={ + <> + + + + } + > +
+ + {productId ? null : ( + + {f.product} + + {errors.productId ? {errors.productId} : null} + + )} + + {f.store} + + {errors.storeId ? {errors.storeId} : null} + +
+ + {f.date} + set("date", e.target.value)} + aria-invalid={Boolean(errors.date)} + /> + {errors.date ? {errors.date} : null} + + + {f.price} + set("price", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.price)} + autoFocus + /> + {errors.price ? {errors.price} : null} + +
+ {preview ? ( + + {f.unitPreview(preview)} + + ) : null} + + set("isPromo", x === true)} + /> + {f.isPromo} + + {v.isPromo ? ( + + {f.promoUntil} + set("promoUntil", e.target.value)} + aria-invalid={Boolean(errors.promoUntil)} + /> + {f.promoUntilHint} + {errors.promoUntil ? {errors.promoUntil} : null} + + ) : null} + + {f.note} + set("note", e.target.value)} /> + + {errors.root ? {errors.root} : null} +
+
+
+ ); +} diff --git a/src/features/prices/components/product-price-view.tsx b/src/features/prices/components/product-price-view.tsx new file mode 100644 index 0000000..7436abb --- /dev/null +++ b/src/features/prices/components/product-price-view.tsx @@ -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 ( + + + + shortDate(v)} + /> + compactTl(v)} + /> + + payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : "" + } + formatter={(value, name) => ( +
+ + `s${s.id}` === name)?.slot ?? 1} /> + {config[String(name)]?.label} + + {formatMoney(Number(value))} +
+ )} + /> + } + /> + } /> + {data.series.map((s) => ( + + ))} +
+
+ ); +} + +/** /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(null); + const p = data.product; + const canAdd = data.canWrite && !p.archived && data.stores.length > 0; + + return ( +
+ + + + {t.detail.typeLink(p.productTypeName)} + + {" · "} + {p.packageLabel} + {p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""} + + } + actions={ + canAdd ? ( + + ) : null + } + /> +
+ {p.archived ? {tr.products.archived} : null} + {p.conversionMissing ? ( + + {tr.products.conversionMissing} + + ) : null} + +
+ + + + {t.detail.currentTitle} + {t.detail.currentDescription} + + + {data.offers.length === 0 ? ( +

{t.noPrices}

+ ) : ( + + )} +
+
+ + {data.history.length > 1 ? ( + + + {t.detail.chartTitle} + {t.detail.chartDescription} + + + + + + ) : null} + + {data.history.length ? ( +
+

+ {t.detail.historyTitle} +

+
    + {data.history.map((r) => ( +
  • +
    +

    + + {r.store.label} +

    +

    + {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}` : ""} +

    +
    +
    +

    {formatMoney(r.priceMinor)}

    + {r.unitPriceMinor !== null ? ( +

    + {formatUnitPrice(r.unitPriceMinor, p.typeUnit)} +

    + ) : null} +
    + {data.canWrite ? ( + + ) : null} +
  • + ))} +
+
+ ) : null} + + {adding ? ( + setAdding(false)} + /> + ) : null} + !o && setDeleting(null)}> + + + {t.detail.deleteTitle} + {t.detail.deleteDescription} + + + {tr.common.cancel} + + + + +
+ ); +} diff --git a/src/features/prices/components/type-price-comparison.tsx b/src/features/prices/components/type-price-comparison.tsx new file mode 100644 index 0000000..69c265a --- /dev/null +++ b/src/features/prices/components/type-price-comparison.tsx @@ -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 ( + + +
+ {t.comparisonTitle} + + {t.comparisonDescription(unitPriceBase(data.typeUnit).unit)} + +
+ {data.canWrite && data.products.length && data.stores.length ? ( + + ) : null} +
+ + {data.products.length === 0 ? ( +

{t.noProducts}

+ ) : data.offers.length === 0 ? ( +

{t.noPrices}

+ ) : ( + + )} +
+ {adding ? ( + setAdding(false)} + /> + ) : null} +
+ ); +} diff --git a/src/features/products/components/product-list.tsx b/src/features/products/components/product-list.tsx index 87091f4..32a9dab 100644 --- a/src/features/products/components/product-list.tsx +++ b/src/features/products/components/product-list.tsx @@ -83,7 +83,11 @@ export function ProductList({
)}
-

{p.label}

+

+ + {p.label} + +

{p.packageLabel} {showType ? ( diff --git a/src/features/stock/components/stock-analysis-view.tsx b/src/features/stock/components/stock-analysis-view.tsx index 448ef5d..ad5b380 100644 --- a/src/features/stock/components/stock-analysis-view.tsx +++ b/src/features/stock/components/stock-analysis-view.tsx @@ -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(); 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 }) {

) : ( <> + {totalCost > 0 ?

{t.totalCost(formatMoney(totalCost))}

: null}
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)} @@ -106,10 +110,11 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
-
+
{t.product} {t.trend(data.months.length)} {t.avgUsed} + {t.avgCost} {t.avgWasted} {t.wastePct} {t.thisMonth} @@ -125,7 +130,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) { return (
  • +

    + + {t.avgCost} + + + {r.avgMonthlyCostMinor === null + ? t.noData + : formatMoney(r.avgMonthlyCostMinor)} + +

    {t.avgWasted}