feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar
Roadmap'in 6 maddesi de gerçek çalışır halde:
1) Geçmiş veri motoru (EKAP v2)
- server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
yaklaşık maliyet, teklif sayısı), TR firma adı normalize
- server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
- server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
- server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
- /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor
2) Gerçek AI şartname analizi
- server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
- pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
- AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata
3) KİK kurul kararları
- server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
- KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri
4) Fiyatlandırma + ödeme
- server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
- Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı
5) TED (AB ihaleleri)
- server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
- rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü
6) Kırmızı bayrak & şeffaflık
- analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
- RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { Gavel, Search, Loader2, ExternalLink, AlertTriangle, Calendar, Building2, Info } from 'lucide-react';
|
||||
|
||||
export const KikDecisions = () => {
|
||||
const { tenders } = useApp();
|
||||
const [q, setQ] = useState('');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const ara = async (terim) => {
|
||||
const t = (terim ?? q).trim();
|
||||
if (!t) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/kik/search?q=${encodeURIComponent(t)}&limit=20`);
|
||||
const d = await res.json();
|
||||
if (d.success) setData(d);
|
||||
else { setError(d.error || 'Arama başarısız'); setData(null); }
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Takipteki ihalelerin idarelerinden hızlı arama önerileri
|
||||
const oneriler = [...new Set(
|
||||
tenders.filter((t) => t.takipte || t.kanbanStatus).map((t) => t.kurum).filter(Boolean)
|
||||
)].slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
<div className="bg-linear-to-r from-slate-900 via-amber-950 to-orange-900 text-white p-6 rounded-2xl shadow-lg border border-amber-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-600 flex items-center justify-center shadow-md">
|
||||
<Gavel className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">KİK Kurul Kararları</h2>
|
||||
<p className="text-xs text-amber-200 mt-0.5">
|
||||
Kamu İhale Kurulu itirazen şikâyet kararlarında arama yapın; emsal kararları teklif öncesi inceleyin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arama */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && ara()}
|
||||
placeholder="Karar metni, idare adı veya başvuran firma…"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg pl-9 pr-4 py-2.5 text-xs font-semibold"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => ara()}
|
||||
disabled={loading || !q.trim()}
|
||||
className="bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white font-bold text-xs px-5 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />} Ara
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{oneriler.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<span className="text-[11px] text-slate-400 font-semibold">Takibinizdeki idareler:</span>
|
||||
{oneriler.map((k) => (
|
||||
<button key={k} onClick={() => { setQ(k); ara(k); }}
|
||||
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md font-medium truncate max-w-[220px]">
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-red-900">KİK kararlarına ulaşılamadı</p>
|
||||
<p className="text-xs text-red-800 mt-0.5 leading-relaxed">{error}</p>
|
||||
<p className="text-[11px] text-red-700 mt-1">
|
||||
KİK sunucusu zaman zaman otomatik isteklere kapalı olabilir; birkaç dakika sonra tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-xs text-slate-500">
|
||||
<strong className="text-slate-900">{data.totalCount?.toLocaleString('tr-TR') ?? data.results.length}</strong> karar bulundu
|
||||
</p>
|
||||
{data.kaynak && <span className="text-[11px] text-slate-400">Kaynak: {data.kaynak}</span>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.results.length === 0 && (
|
||||
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
|
||||
<p className="text-sm font-bold text-slate-500">Bu aramada karar bulunamadı</p>
|
||||
</div>
|
||||
)}
|
||||
{data.results.map((k, i) => (
|
||||
<div key={k.id || i} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{k.kararNo && (
|
||||
<span className="text-[10px] font-extrabold bg-amber-100 text-amber-900 px-2 py-0.5 rounded">
|
||||
{k.kararNo}
|
||||
</span>
|
||||
)}
|
||||
{k.kararTarihi && (
|
||||
<span className="text-[11px] text-slate-500 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" /> {k.kararTarihi}
|
||||
</span>
|
||||
)}
|
||||
{k.tur && <span className="text-[10px] bg-slate-100 text-slate-700 px-2 py-0.5 rounded font-bold">{k.tur}</span>}
|
||||
</div>
|
||||
<h3 className="font-bold text-sm text-slate-900 mt-1.5 leading-snug">{k.baslik || k.konu || 'Kurul Kararı'}</h3>
|
||||
</div>
|
||||
{k.link && (
|
||||
<a href={k.link} target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:text-amber-900 shrink-0" title="Kararı aç">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(k.idare || k.basvuran) && (
|
||||
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-600">
|
||||
{k.idare && <span className="flex items-center gap-1"><Building2 className="w-3 h-3 text-slate-400" /> {k.idare}</span>}
|
||||
{k.basvuran && <span>Başvuran: <strong>{k.basvuran}</strong></span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{k.ozet && <p className="text-xs text-slate-600 leading-relaxed line-clamp-3">{k.ozet}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex gap-3">
|
||||
<Info className="w-4 h-4 text-blue-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-blue-900 leading-relaxed">
|
||||
Bir idare adı, firma veya konu yazıp arayın. KİK kurul kararları, benzer ihalelerde
|
||||
hangi itirazların kabul/ret edildiğini gösterir — teklif ve şikâyet stratejinizi buna göre kurabilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { getToken } from '../../services/auth';
|
||||
import { Check, Sparkles, CreditCard, Loader2, Info } from 'lucide-react';
|
||||
|
||||
export const Pricing = () => {
|
||||
const { authUser } = useApp();
|
||||
const [data, setData] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [yillik, setYillik] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/billing/plans')
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => setData({ configured: false, plans: [] }));
|
||||
}, []);
|
||||
|
||||
const upgrade = async (planId) => {
|
||||
setBusy(planId);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ planId }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success && d.url) window.location.href = d.url;
|
||||
else setError(d.error || 'Ödeme başlatılamadı');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const plans = data?.plans || [];
|
||||
const fiyatla = (p) => (yillik ? Math.round(p.fiyat * 12 * 0.83) : p.fiyat);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
<div className="bg-linear-to-r from-slate-900 via-blue-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-blue-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-md">
|
||||
<CreditCard className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">Planlar & Fiyatlandırma</h2>
|
||||
<p className="text-xs text-blue-200 mt-0.5">İhtiyacınıza uygun planı seçin. İstediğiniz zaman iptal edebilirsiniz.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aylık / Yıllık */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex text-xs font-bold shadow-xs">
|
||||
<button onClick={() => setYillik(false)} className={`px-4 py-2 rounded-lg transition-colors ${!yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
||||
Aylık
|
||||
</button>
|
||||
<button onClick={() => setYillik(true)} className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-1.5 ${yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
||||
Yıllık <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${yillik ? 'bg-amber-400 text-slate-900' : 'bg-emerald-100 text-emerald-800'}`}>%17 indirim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-xs text-red-800 text-center">{error}</div>
|
||||
)}
|
||||
|
||||
{data && !data.configured && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
|
||||
<Info className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-900 leading-relaxed">
|
||||
<strong>Ödeme altyapısı henüz bağlanmadı.</strong> Satın alma için sunucuya <code className="bg-amber-100 px-1 rounded">STRIPE_SECRET_KEY</code> ve
|
||||
plan fiyat kimlikleri (<code className="bg-amber-100 px-1 rounded">STRIPE_PRICE_PRO</code>, <code className="bg-amber-100 px-1 rounded">STRIPE_PRICE_KURUMSAL</code>) eklenmeli.
|
||||
Planlar aşağıda görüntülenebilir.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{plans.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`rounded-2xl border p-6 flex flex-col gap-4 shadow-xs ${
|
||||
p.vurgu ? 'border-blue-500 border-2 bg-linear-to-b from-blue-50 to-white shadow-lg' : 'border-slate-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-black text-lg text-slate-900">{p.ad}</h3>
|
||||
{p.rozet && (
|
||||
<span className="bg-blue-600 text-white text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wide flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> {p.rozet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{p.ozet}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-3xl font-black text-slate-900">
|
||||
{p.fiyat === 0 ? 'Ücretsiz' : `₺${fiyatla(p).toLocaleString('tr-TR')}`}
|
||||
</span>
|
||||
{p.fiyat > 0 && <span className="text-xs text-slate-500 font-medium"> /{yillik ? 'yıl' : 'ay'}</span>}
|
||||
{p.fiyat > 0 && <p className="text-[11px] text-slate-400 mt-0.5">KDV hariç</p>}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 text-xs text-slate-700 flex-1">
|
||||
{p.ozellikler.map((o, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<Check className="w-3.5 h-3.5 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<span>{o}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{p.fiyat === 0 ? (
|
||||
<div className="text-center text-xs font-bold text-slate-500 bg-slate-50 border border-slate-200 rounded-xl py-2.5">
|
||||
Mevcut planınız
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => upgrade(p.id)}
|
||||
disabled={!p.satinAlinabilir || busy === p.id || !authUser}
|
||||
title={!authUser ? 'Önce giriş yapın' : !p.satinAlinabilir ? 'Ödeme altyapısı bağlanmadı' : ''}
|
||||
className={`w-full font-extrabold text-xs py-3 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
p.vurgu ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-slate-900 hover:bg-slate-800 text-white'
|
||||
}`}
|
||||
>
|
||||
{busy === p.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <CreditCard className="w-4 h-4" />}
|
||||
{busy === p.id ? 'Yönlendiriliyor…' : `${p.ad}'a Geç`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center leading-relaxed max-w-2xl mx-auto">
|
||||
Ödemeler Stripe üzerinden güvenli şekilde alınır; kart bilgileriniz İhaleist sunucularında saklanmaz.
|
||||
Aboneliğinizi istediğiniz zaman iptal edebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { redFlags, marketConcentration, formatTL } from '../../services/analytics';
|
||||
import { ShieldAlert, AlertTriangle, TrendingDown, Building2, Users, Info } from 'lucide-react';
|
||||
|
||||
const SEVIYE = {
|
||||
yuksek: { etiket: 'Yüksek', kutu: 'bg-red-50 border-red-200', rozet: 'bg-red-100 text-red-800', cizgi: 'bg-red-500' },
|
||||
orta: { etiket: 'Orta', kutu: 'bg-amber-50 border-amber-200', rozet: 'bg-amber-100 text-amber-800', cizgi: 'bg-amber-500' },
|
||||
dusuk: { etiket: 'Düşük', kutu: 'bg-slate-50 border-slate-200', rozet: 'bg-slate-100 text-slate-700', cizgi: 'bg-slate-400' },
|
||||
};
|
||||
|
||||
const TUR_IKON = {
|
||||
'Tek teklif': Users,
|
||||
'Düşük kırım': TrendingDown,
|
||||
'Tahmin üstü bedel': AlertTriangle,
|
||||
'Aşırı düşük teklif': TrendingDown,
|
||||
'Tedarikçi yoğunlaşması': Building2,
|
||||
};
|
||||
|
||||
export const RedFlags = () => {
|
||||
const { tenders, setActiveTenderDetail } = useApp();
|
||||
const { flags, yuksek, orta, analiz } = redFlags(tenders);
|
||||
const { hhi, seviye } = marketConcentration(tenders);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-linear-to-r from-slate-900 via-rose-950 to-red-900 text-white p-6 rounded-2xl shadow-lg border border-rose-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-rose-600 text-white flex items-center justify-center shadow-md">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">Kırmızı Bayrak & Şeffaflık Analizi</h2>
|
||||
<p className="text-xs text-rose-200 mt-0.5">Tek teklifli ihaleler, olağandışı kırım oranları ve tedarikçi yoğunlaşması otomatik tespit edilir.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Özet kartlar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Yüksek Öncelik</span>
|
||||
<span className="text-2xl font-black text-red-600 mt-1 block">{yuksek}</span>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Orta Öncelik</span>
|
||||
<span className="text-2xl font-black text-amber-600 mt-1 block">{orta}</span>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Analiz Edilen</span>
|
||||
<span className="text-2xl font-black text-slate-900 mt-1 block">{analiz}</span>
|
||||
<span className="text-[11px] text-slate-400">sonuç verisi olan ihale</span>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Pazar Yoğunlaşması</span>
|
||||
<span className="text-2xl font-black text-indigo-900 mt-1 block">{hhi != null ? hhi : '—'}</span>
|
||||
<span className="text-[11px] text-slate-400">{seviye || 'HHI için sonuç verisi gerekli'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yöntem notu */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex gap-3">
|
||||
<Info className="w-4 h-4 text-blue-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-blue-900 leading-relaxed">
|
||||
Bu sinyaller kamuya açık ihale verisinden otomatik hesaplanır ve <strong>incelenmeye değer durumları</strong> işaret eder —
|
||||
tek başlarına usulsüzlük kanıtı değildir. Tek teklif ve kırım analizleri, ilgili alanların (teklif sayısı, yaklaşık maliyet,
|
||||
sözleşme bedeli) veride bulunduğu ihaleler için üretilir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bayrak listesi */}
|
||||
{flags.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
|
||||
<ShieldAlert className="w-8 h-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-bold text-slate-500">Henüz kırmızı bayrak tespit edilmedi</p>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-md mx-auto leading-relaxed">
|
||||
Analiz için sonuç verisi (kazanan firma, sözleşme bedeli, teklif sayısı) gerekir.
|
||||
Doğrudan temin sonuçları ve EKAP sonuç ilanları biriktikçe bu liste otomatik dolar.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{flags.map((f) => {
|
||||
const s = SEVIYE[f.seviye];
|
||||
const Icon = TUR_IKON[f.tur] || AlertTriangle;
|
||||
return (
|
||||
<div key={f.id} className={`rounded-xl border ${s.kutu} p-4 flex gap-3 items-start`}>
|
||||
<span className={`w-1 self-stretch rounded-full ${s.cizgi} shrink-0`} />
|
||||
<Icon className="w-4 h-4 text-slate-600 shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded-full ${s.rozet}`}>{s.etiket}</span>
|
||||
<span className="text-xs font-extrabold text-slate-900">{f.tur}</span>
|
||||
{f.deger && <span className="text-[11px] font-bold text-slate-600 bg-white/70 border border-slate-200 px-2 py-0.5 rounded">{f.deger}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 mt-1.5 leading-relaxed">{f.aciklama}</p>
|
||||
{f.tender ? (
|
||||
<button
|
||||
onClick={() => setActiveTenderDetail(f.tender)}
|
||||
className="text-[11px] font-bold text-blue-700 hover:underline mt-1.5 text-left"
|
||||
>
|
||||
{f.tender.baslik}
|
||||
{f.tender.kurum ? ` — ${f.tender.kurum}` : ''}
|
||||
{f.tender.sozlesmeBedeli != null ? ` (${formatTL(Number(f.tender.sozlesmeBedeli))})` : ''}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-[11px] font-bold text-slate-600 mt-1.5">{f.kurum}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Globe2, Loader2, ExternalLink, Building2, Calendar, Trophy, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
const MODLAR = [
|
||||
{ id: 'acik', label: 'Açık AB İhaleleri', aciklama: 'Türkiye’deki idarelerin AB’de yayımladığı, hâlen teklif verilebilir ihaleler.' },
|
||||
{ id: 'turkiye', label: 'Tüm Türkiye Kayıtları', aciklama: 'TR idareler + TR kazananlar + TR teklif verenler.' },
|
||||
{ id: 'kazanan', label: 'AB’de Kazanan TR Firmaları', aciklama: 'Türk firmalarının AB ihalelerinde kazandığı sözleşmeler.' },
|
||||
];
|
||||
|
||||
export const TedTenders = () => {
|
||||
const [mod, setMod] = useState('acik');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const yukle = (m) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/ted?mod=${m}&limit=30`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.success) setData(d); else setError(d.error); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { yukle(mod); }, [mod]);
|
||||
|
||||
const aktifMod = MODLAR.find((m) => m.id === mod);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
<div className="bg-linear-to-r from-slate-900 via-blue-950 to-sky-900 text-white p-6 rounded-2xl shadow-lg border border-sky-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-sky-600 flex items-center justify-center shadow-md">
|
||||
<Globe2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">AB İhaleleri — TED</h2>
|
||||
<p className="text-xs text-sky-200 mt-0.5">
|
||||
Tenders Electronic Daily (AB resmi ihale yayını) üzerinden Türkiye ile ilgili ihaleler.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mod seçici */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{MODLAR.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setMod(m.id)}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold border transition-colors ${
|
||||
mod === m.id ? 'bg-sky-600 text-white border-sky-600 shadow-xs' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => yukle(mod)}
|
||||
disabled={loading}
|
||||
className="px-3 py-2 rounded-xl text-xs font-bold border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Yenile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between gap-3 flex-wrap shadow-xs">
|
||||
<p className="text-xs text-slate-600">{aktifMod?.aciklama}</p>
|
||||
{data && (
|
||||
<span className="text-xs font-bold text-sky-800 bg-sky-50 border border-sky-200 px-3 py-1 rounded-full shrink-0">
|
||||
{data.totalCount.toLocaleString('tr-TR')} kayıt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-red-900">TED verisi alınamadı</p>
|
||||
<p className="text-xs text-red-800 mt-0.5">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !data && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
|
||||
<Loader2 className="w-6 h-6 text-sky-600 animate-spin mx-auto" />
|
||||
<p className="text-xs text-slate-500 mt-3">TED'den çekiliyor…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-3">
|
||||
{data.tenders.length === 0 && (
|
||||
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
|
||||
<p className="text-sm font-bold text-slate-500">Bu filtrede kayıt bulunamadı</p>
|
||||
</div>
|
||||
)}
|
||||
{data.tenders.map((t) => (
|
||||
<div key={t.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="font-bold text-sm text-slate-900 leading-snug">{t.baslik}</h3>
|
||||
{t.link && (
|
||||
<a href={t.link} target="_blank" rel="noopener noreferrer"
|
||||
className="text-sky-600 hover:text-sky-800 shrink-0" title="TED'de aç">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-600">
|
||||
<span className="flex items-center gap-1 font-semibold">
|
||||
<Building2 className="w-3 h-3 text-slate-400" /> {t.kurum}
|
||||
</span>
|
||||
{t.sehir && <span>{t.sehir}</span>}
|
||||
{t.ulke && <span className="bg-slate-100 px-1.5 py-0.5 rounded font-bold">{t.ulke}</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-blue-500" />
|
||||
{t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi}
|
||||
</span>
|
||||
<span className="bg-sky-50 text-sky-800 border border-sky-200 px-1.5 py-0.5 rounded font-bold">{t.alimTuru}</span>
|
||||
{t.sonucIlani && <span className="bg-emerald-50 text-emerald-800 border border-emerald-200 px-1.5 py-0.5 rounded font-bold">Sonuç İlanı</span>}
|
||||
</div>
|
||||
|
||||
{(t.kazananFirma || t.yaklasikMaliyet != null) && (
|
||||
<div className="flex items-center gap-4 flex-wrap pt-2 border-t border-slate-100 text-[11px]">
|
||||
{t.kazananFirma && (
|
||||
<span className="flex items-center gap-1 font-bold text-emerald-800">
|
||||
<Trophy className="w-3 h-3" /> {t.kazananFirma}
|
||||
{t.kazananUlke && <span className="text-slate-400 font-normal">({t.kazananUlke})</span>}
|
||||
</span>
|
||||
)}
|
||||
{t.yaklasikMaliyet != null && (
|
||||
<span className="font-black text-indigo-900">
|
||||
{t.yaklasikMaliyet.toLocaleString('tr-TR')} {t.paraBirimi || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center">
|
||||
Kaynak: Tenders Electronic Daily (ted.europa.eu) — Avrupa Birliği resmi ihale yayını.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user