- server/ted-ceviri.mjs: TED'in kendi Türkçe alanı varsa onu kullanır; yoksa OpenRouter ile toplu çevirir (deepseek-v4-flash → gemini 3.7 flash → glm), sonucu server/data/ted.sqlite'ta ilan numarasına göre saklar - İstek en fazla 12 sn bekler, kalan çeviriler arka planda sürer; yanıt ceviriBekleniyor bayrağı taşır, ön yüz sessizce yeniden çeker - Kartta Türkçe başlık + küçük özgün başlık, Türkçe açıklama (3 satır), ülke kodları Türkçe ad (TUR → Türkiye, AUT → Avusturya…) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
184 lines
8.8 KiB
React
184 lines
8.8 KiB
React
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.' },
|
||
];
|
||
|
||
const ULKE_TR = {
|
||
TUR: 'Türkiye', DEU: 'Almanya', FRA: 'Fransa', ITA: 'İtalya', ESP: 'İspanya', NLD: 'Hollanda', BEL: 'Belçika',
|
||
AUT: 'Avusturya', POL: 'Polonya', ROU: 'Romanya', BGR: 'Bulgaristan', GRC: 'Yunanistan', HRV: 'Hırvatistan',
|
||
HUN: 'Macaristan', CZE: 'Çekya', SVK: 'Slovakya', SVN: 'Slovenya', PRT: 'Portekiz', SWE: 'İsveç', DNK: 'Danimarka',
|
||
FIN: 'Finlandiya', NOR: 'Norveç', IRL: 'İrlanda', LTU: 'Litvanya', LVA: 'Letonya', EST: 'Estonya', CYP: 'Kıbrıs',
|
||
MLT: 'Malta', LUX: 'Lüksemburg', CHE: 'İsviçre', GBR: 'İngiltere', SRB: 'Sırbistan', MKD: 'K. Makedonya',
|
||
ALB: 'Arnavutluk', BIH: 'Bosna-Hersek', MNE: 'Karadağ', XKX: 'Kosova', UKR: 'Ukrayna', MDA: 'Moldova', ISL: 'İzlanda',
|
||
};
|
||
|
||
export const TedTenders = () => {
|
||
const [mod, setMod] = useState('acik');
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
|
||
// Çeviriler sunucuda arka planda tamamlanır; "bekleniyor" geldiği sürece
|
||
// sessizce (yükleniyor göstergesi olmadan) birkaç kez yeniden çekilir.
|
||
const yenidenDeneme = React.useRef(0);
|
||
const yukle = (m, sessiz = false) => {
|
||
if (!sessiz) { setLoading(true); setError(null); yenidenDeneme.current = 0; }
|
||
fetch(`/api/ted?mod=${m}&limit=30`)
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.success) { if (!sessiz) setError(d.error); return; }
|
||
setData(d);
|
||
if (d.ceviriBekleniyor && yenidenDeneme.current < 12) {
|
||
yenidenDeneme.current += 1;
|
||
setTimeout(() => yukle(m, true), 7000);
|
||
}
|
||
})
|
||
.catch((e) => { if (!sessiz) setError(e.message); })
|
||
.finally(() => { if (!sessiz) 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?.ceviriBekleniyor && (
|
||
<p className="text-[11px] text-slate-500 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> Başlıklar Türkçeye çevriliyor, birkaç saniye içinde güncellenir…</p>
|
||
)}
|
||
{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">
|
||
<div className="min-w-0">
|
||
<h3 className="font-bold text-sm text-slate-900 leading-snug">{t.baslikTr || t.baslik}</h3>
|
||
{t.baslikTr && t.baslikTr !== t.baslik && (
|
||
<p className="text-[11px] italic text-slate-400 leading-snug mt-0.5">{t.baslik}</p>
|
||
)}
|
||
</div>
|
||
{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">{ULKE_TR[t.ulke] || 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.aciklamaTr || t.aciklama) && (
|
||
<p className="text-xs text-slate-600 leading-relaxed line-clamp-3">{t.aciklamaTr || t.aciklama}</p>
|
||
)}
|
||
|
||
{(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">({ULKE_TR[t.kazananUlke] || 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>
|
||
);
|
||
};
|