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.
151 lines
7.9 KiB
React
151 lines
7.9 KiB
React
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>
|
||
);
|
||
};
|