Files
ihaleist/src/components/tools/PriceAnalysis.jsx
T
hikmettan 1838de95f5 feat: ihale dokümanları + OKAS kalem verisi + doğrudan temin (Playwright) + kalıcı arşiv
1) İhale evrakları (yeni)
   - server/documents.mjs: EKAP doküman URL üretimi (imzalı), ilan metni +
     OKAS kalem çıkarımı, Playwright ile doküman metni (varsa)
   - /api/documents/url · /ilan · /text · /config uçları
   - TenderDocuments paneli: 'EKAP Dokümanlarını Aç' + 'İlan Metnini AI ile
     Analiz Et' (11K+ karakter gerçek ilan metni doğrudan AI'ya gider)
   - NOT: eski EKAP host'u bot isteklerine bağlantı kesiyor; bağlantı
     kullanıcının tarayıcısında açılır, sunucu tarafı metin için Chromium gerekir

2) Kalem verisi gerçek oldu
   - ekap-scan: her ihale için IhaleDetay'dan OKAS ihtiyaç kalemleri çekilir
   - history-store: okas_kalemler kolonu + otomatik şema geçişi
   - --kalem-doldur modu: mevcut kayıtlara IKN'den çözerek kalem ekler
   - scheduler: tarama sonrası kalem doldurmayı zincirler
   - PriceAnalysis: yöntem şeffaflığı notu (birim fiyatlar kamuya açık değil;
     kategori bazlı yaklaşık değer olduğu açıkça yazıyor)

3) Doğrudan temin (Playwright)
   - Dockerfile: node:22-slim + nginx + Playwright/Chromium
   - scheduler: DT worker periyodik (DT_INTERVAL_SAAT, varsayılan 4 saat)
   - Bellek koruması: boş bellek DT_MIN_BOS_MB altındaysa tarama atlanır ve
     uyarı loglanır; DT_ENABLED=0 ile tamamen kapatılabilir

4) Kalıcı arşiv
   - Coolify'da /app/server/data volume bağlandı; arşiv artık deploy'lar
     arası korunuyor

Ayrıca: TenderDetailModal'daki sabit '3-5 Firma' tahmini kaldırıldı, yerine
gerçek geçerli teklif sayısı gösteriliyor.
2026-08-28 22:31:40 +03:00

151 lines
7.9 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 React, { useState, useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell } from 'recharts';
import { TrendingUp, Download, Sparkles, Inbox } from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { itemStats, formatTL } from '../../services/analytics';
import confetti from 'canvas-confetti';
export const PriceAnalysis = () => {
const { tenders } = useApp();
const { items, itemCount, uniqueItems } = useMemo(() => itemStats(tenders), [tenders]);
const [selectedKey, setSelectedKey] = useState(null);
const selected = useMemo(() => {
if (!items.length) return null;
const found = items.find((i) => i.ad === selectedKey);
return found || items[0];
}, [items, selectedKey]);
const chartData = selected
? [
{ label: 'En Düşük', value: Math.round(selected.min), renk: '#2563eb' },
{ label: 'Medyan', value: Math.round(selected.median), renk: '#059669' },
{ label: 'Ortalama', value: Math.round(selected.avg), renk: '#f59e0b' },
{ label: 'En Yüksek', value: Math.round(selected.max), renk: '#dc2626' },
]
: [];
const handleDownloadReport = () => {
if (!items.length) return;
const headers = ['Kalem', 'Birim', 'Adet(kayıt)', 'Min', 'Medyan', 'Ortalama', 'Maks'];
const rows = items.map((i) => [
`"${(i.ad || '').replace(/"/g, '""')}"`, i.birim || '', i.count,
Math.round(i.min), Math.round(i.median), Math.round(i.avg), Math.round(i.max),
]);
const csv = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
const link = document.createElement('a');
link.href = encodeURI(csv);
link.download = `ihaleist_fiyat_analizi_${Date.now()}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
confetti({ particleCount: 60 });
};
return (
<div className="space-y-4 my-4 animate-fade-in">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shadow-md">
<TrendingUp className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1>
<p className="text-xs text-slate-500">
EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · <strong>{uniqueItems}</strong> farklı kalem kategorisi, <strong>{itemCount}</strong> kayıt.
</p>
</div>
</div>
<button
onClick={handleDownloadReport}
disabled={!items.length}
className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-2 shadow-xs shrink-0"
>
<Download className="w-4 h-4" /> CSV İndir
</button>
</div>
{/* Yöntem şeffaflığı */}
{items.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3.5 flex gap-2.5">
<span className="text-blue-600 font-bold shrink-0">ℹ</span>
<p className="text-[11px] text-blue-900 leading-relaxed">
<strong>Yöntem:</strong> Türkiye'de ihale <em>teklif birim fiyatları</em> kamuya açık değildir.
Buradaki tutarlar, EKAP sonuç ilanındaki <strong>sözleşme bedelinin</strong> ihalenin OKAS
ihtiyaç kalemlerine bölünmesiyle elde edilen <strong>kategori bazlı yaklaşık</strong> değerlerdir —
kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın.
</p>
</div>
)}
{items.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-slate-300" />
Henüz kalem verisi birikmedi. EKAP sonuç ilanları ve OKAS ihtiyaç kalemleri arşive eklendikçe bu analiz otomatik dolar.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-2 h-fit max-h-[520px] overflow-y-auto">
<h3 className="font-bold text-xs uppercase tracking-wider text-slate-800 border-b border-slate-100 pb-2 sticky top-0 bg-white">
Kalemler ({items.length})
</h3>
{items.slice(0, 60).map((it) => (
<button
key={it.ad}
onClick={() => setSelectedKey(it.ad)}
className={`w-full text-left p-2.5 rounded-lg text-xs font-semibold transition-all ${
selected?.ad === it.ad ? 'bg-emerald-600 text-white' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200'
}`}
>
<div className="truncate">📦 {it.ad}</div>
<div className={`text-[10px] ${selected?.ad === it.ad ? 'text-emerald-100' : 'text-slate-400'}`}>{it.count} kayıt · medyan {formatTL(it.median)}</div>
</button>
))}
</div>
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Birim Fiyat Dağılımı (₺)</h3>
<span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">{selected?.count} gerçek kayıt</span>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(v) => formatTL(v)} />
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
{chartData.map((d, i) => <Cell key={i} fill={d.renk} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
{chartData.map((d) => (
<div key={d.label} className="bg-slate-50 rounded-lg p-2 border border-slate-100">
<div className="text-[10px] text-slate-500">{d.label}</div>
<div className="text-sm font-black" style={{ color: d.renk }}>{formatTL(d.value)}</div>
</div>
))}
</div>
</div>
<div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-2 shadow-md border border-emerald-900/40">
<div className="flex items-center gap-2 font-bold text-sm text-emerald-400">
<Sparkles className="w-5 h-5 text-amber-400" /> Teklif Önerisi (gerçek veriye dayalı)
</div>
<p className="text-xs text-slate-300 leading-relaxed">
<strong>{selected?.ad}</strong> için toplanan {selected?.count} gerçek kayda göre rekabetçi birim fiyat aralığınız{' '}
<strong className="text-amber-300">{formatTL(selected?.min)} – {formatTL(selected?.median)}</strong> arasıdır.
Medyanın altında teklif vermek kazanma olasılığını artırır; ortalamanın ({formatTL(selected?.avg)}) üstü riskli olabilir.
</p>
</div>
</div>
</div>
)}
</div>
);
};