Files
sase.tr/apps/web/src/components/catalog/part-price-chart.tsx
Semih Yesilyurt 532e1ad9ef feat(part-prices): parça kodu bazlı tedarikçi fiyat geçmişi + OEM sayfasında p50/p95/p99 grafiği
- pg: part_price_tracks + part_price_daily (0018) — (kod, kaynak, gün) başına
  stoktaki tekliflerin p50/p95/p99 + teklif sayısı; source='supplier' şimdilik,
  perakende ileride aynı tabloya 'retail' olarak girer. Tedarikçi kimliği yok.
- API: GET /part-prices/series (ilk istekte takip history'sinden lazy-backfill,
  sonrası salt-pg + Redis) ve POST /part-prices/current-batch (sayfadaki kodlar
  için canlı güncel istatistik). P-servisi sözleşmesi: asla throw yok, fail-open.
- Worker: part-price-refresh cron'u 19:30 Europe/Istanbul (takip sync'i 19:05'te
  bitiyor) — izlenen kodlara bugünün satırını upsert eder, sku_map'i artımlı
  bakar, Redis cache düşürür. SUPPLIER_PRICE_DB_* yoksa sessiz no-op.
- Kaynak köprüsü: takip.sku_map (code_norm → product_id; tam sku / ilk-boşluk /
  ilk-tire sonrası normalize adayları) vmi MySQL'inde kurulu; 6,8M satır.
- Web: OEM detayında "Tedarikçi fiyat analizi" kartı (güncel medyan + P95/P99 +
  teklif sayısı + 30g delta, 30G/90G/Tümü aralıklı step grafik, recharts) ve
  article/muadil/OE satırlarında fiyat çipi → dialog'da tam geçmiş.
- Fix(p): td snapshot'ında gerçek üretici kodu articles.name'de (article_number
  %96 upstream sayısal ID) — sayfa artık kopyalanabilir gerçek kodu gösteriyor.
- compose: SUPPLIER_PRICE_DB_ENABLED/URL api+worker bloklarına eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:33:37 +03:00

137 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type ChartDatum,
formatChartDate,
formatDateLong,
formatTry,
formatTryCompact,
} from "@/lib/part-prices";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
/** Step grafiği — fiyatlar değişim noktalı geldiği için basamaklı çizilir;
* P50 dolgu alanlı ana seri, P95 kesikli, P99 noktalı yardımcı çizgiler.
* Renkler tema token'larından (koyu temada otomatik uyumlu). */
interface TooltipRowProps {
label: string;
value: number | null;
swatchClass: string;
}
function TooltipRow({ label, value, swatchClass }: TooltipRowProps) {
if (value === null) return null;
return (
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className={`inline-block h-0.5 w-3 rounded-full ${swatchClass}`} />
{label}
</span>
<span className="font-mono font-medium tabular-nums">{formatTry(value)}</span>
</div>
);
}
interface PriceTooltipProps {
active?: boolean;
payload?: ReadonlyArray<{ payload?: ChartDatum }>;
}
function PriceTooltip({ active, payload }: PriceTooltipProps) {
const datum = payload?.[0]?.payload;
if (!active || !datum) return null;
return (
<div className="min-w-40 rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md">
<p className="mb-1.5 font-medium">{formatDateLong(datum.date)}</p>
{datum.offerCount === 0 ? (
<p className="text-muted-foreground">Stokta teklif yok</p>
) : (
<div className="space-y-1">
<TooltipRow label="Medyan (P50)" value={datum.p50} swatchClass="bg-brand" />
<TooltipRow label="P95" value={datum.p95} swatchClass="bg-muted-foreground" />
<TooltipRow label="P99" value={datum.p99} swatchClass="bg-muted-foreground/60" />
<p className="pt-0.5 text-muted-foreground">{datum.offerCount} stoktaki teklif</p>
</div>
)}
</div>
);
}
export function PartPriceChart({ data }: { data: ChartDatum[] }) {
return (
<div className="h-52 w-full" aria-hidden>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
<defs>
<linearGradient id="partPriceP50Fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand)" stopOpacity={0.22} />
<stop offset="100%" stopColor="var(--color-brand)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--color-border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="ts"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={formatChartDate}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickMargin={8}
minTickGap={48}
/>
<YAxis
tickFormatter={formatTryCompact}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
width={64}
domain={["auto", "auto"]}
/>
<Tooltip content={<PriceTooltip />} cursor={{ stroke: "var(--color-border)" }} />
<Area
type="stepAfter"
dataKey="p50"
stroke="var(--color-brand)"
strokeWidth={2}
fill="url(#partPriceP50Fill)"
connectNulls={false}
dot={false}
activeDot={{ r: 3 }}
isAnimationActive={false}
/>
<Line
type="stepAfter"
dataKey="p95"
stroke="var(--color-muted-foreground)"
strokeWidth={1.25}
strokeDasharray="5 4"
connectNulls={false}
dot={false}
isAnimationActive={false}
/>
<Line
type="stepAfter"
dataKey="p99"
stroke="var(--color-muted-foreground)"
strokeOpacity={0.55}
strokeWidth={1.25}
strokeDasharray="2 4"
connectNulls={false}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}