Firmalar ihalenin sadece basligina bakarak kendi urunlerinin icinde olup
olmadigini goremiyordu ("148 KALEM KIRTASIYE MALZEME ALIMI"). Artik bir
kullanici ihale dokumanini yukledinde teklif cetveli kalemleri ayristirilip
IKN'ye baglaniyor ve o ihale HERKESIN kartinda kalemleriyle gorunuyor.
NEDEN YUKLEMEYE DAYALI: kalem listesi EKAP'in programatik yuzeyinde yok.
- Arama/detay API'si (ihtiyacKalemiOkasList) yalnizca OKAS sinifi veriyor:
"KALP AKCIGER POMPASI CIHAZI ALIMI" icin tek satir "Tibbi cihazlar"
- Ilan metni yalnizca sayiyi veriyor: "148 KALEM ... Ayrintili bilgiye EKAP'ta
yer alan ihale dokumani icinde bulunan idari sartnameden ulasilabilir"
- Dokuman sayfasi CAPTCHA ile korunuyor (ctl00_btnCaptchaProtect, guvenlik
resmi). Gercek tarayiciyla (Playwright) da acildi, donen tek metin: "Dogrula"
OTOMATIK, AI'SIZ: kalem cikarimi yukleme aninda kendiliginden calisir,
kullanicidan tiklama beklemez ve model cagirmaz (saf regex). AI analizi ayri
dugmede, kullanici isterse.
- server/kalem-cikar.mjs: teklif cetveli ayristirici. " | " tablolarindan
(docx/xlsx/zip) sutun basligini cozerek; ayracsiz PDF metninde sezgisel
satir kalibiyla. Miktar+birim tek hucrede olabiliyor ("25000 TEST").
Birim listesinde 'test' var — tibbi kitlerde kalem miktari boyle yaziliyor.
- server/kalem-store.mjs: kalem.sqlite, IKN bazli, Turkce duyarli arama.
Bos ayristirma mevcut iyi veriyi silmez.
- Uclar: POST /api/kalem/cikar (tarayicidan gelen PDF metni), GET /api/kalem,
GET /api/kalem/ara, GET /api/health/kalem
- /api/tenders payloadu kalemSayisi + ilk kalemleri tasiyor
- Kartta kalem rozeti ve kalem seridi; aramada eslesen kalemler one alinir
- Ust arama kutusu kalem havuzunda da ariyor (350ms debounce, sunucu tarafi)
- Detay modalinda tam kalem listesi + kalem ici arama
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
11 KiB
React
228 lines
11 KiB
React
import React from 'react';
|
||
import { useApp } from '../context/AppContext';
|
||
import { Star, MapPin, Clock, Tag, Package, Building2, AlertTriangle, Sparkles, CalendarDays, Megaphone } from 'lucide-react';
|
||
|
||
export const TenderCard = ({ tender }) => {
|
||
const { toggleTrackTender, setActiveTenderDetail, favoriteKurumlar, toggleFavoriteKurum, kalemEslesen, searchQuery } = useApp();
|
||
// Aramada eşleşen kalemler öne alınır — firma kendi ürününü hemen görsün.
|
||
const eslesenKalemler = (kalemEslesen && tender.ikn && kalemEslesen.get(tender.ikn)) || null;
|
||
const isFavKurum = favoriteKurumlar?.includes((tender.kurum || '').trim());
|
||
|
||
const [day, monthYear] = tender.tarih ? tender.tarih.split('.') : ['01', '01.2026'];
|
||
const monthYearFormatted = monthYear ? `${monthYear}` : '08.2026';
|
||
|
||
// İlan (yayım) tarihi kaynaklara göre farklı biçimde geliyor:
|
||
// canlı akış ISO (2026-08-30), DT/arşiv gg.aa.yyyy. İkisini de gösterebilelim.
|
||
const ilanTarihiGoster = (() => {
|
||
const v = tender.ilanTarihi;
|
||
if (!v) return null;
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(v)) {
|
||
const [y, a, g] = v.slice(0, 10).split('-');
|
||
return `${g}.${a}.${y}`;
|
||
}
|
||
return v;
|
||
})();
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-blue-200/80 shadow-xs hover:shadow-md transition-all overflow-hidden flex flex-col md:flex-row group border-l-4 border-l-blue-600">
|
||
|
||
{/* Left Date Block */}
|
||
<div
|
||
onClick={() => setActiveTenderDetail(tender)}
|
||
className="bg-[#edf4ff] group-hover:bg-[#e2edff] text-blue-900 md:w-36 p-4 flex flex-col items-center justify-center shrink-0 border-b md:border-b-0 md:border-r border-blue-100 cursor-pointer select-none transition-colors"
|
||
>
|
||
<span className="text-[9px] font-black uppercase tracking-widest text-blue-900/50">İhale</span>
|
||
<span className="text-3xl font-black tracking-tight text-blue-700 leading-none">{day}</span>
|
||
<span className="text-xs font-bold text-blue-900/80 mt-0.5">{monthYearFormatted}</span>
|
||
<span className="text-[11px] font-semibold bg-white/80 px-2 py-0.5 rounded-full text-blue-800 mt-2 border border-blue-200">
|
||
{tender.saat}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Main Card Content */}
|
||
<div className="flex-1 p-4 flex flex-col justify-between space-y-3">
|
||
|
||
{/* Top Header Row */}
|
||
<div>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h3
|
||
onClick={() => setActiveTenderDetail(tender)}
|
||
className="font-bold text-sm sm:text-base text-slate-900 hover:text-blue-600 cursor-pointer leading-snug tracking-tight"
|
||
>
|
||
{tender.baslik}
|
||
{tender.isYeni && (
|
||
<span className="ml-2 inline-block bg-purple-600 text-white text-[10px] font-extrabold px-2 py-0.5 rounded-md uppercase tracking-wider">
|
||
YENİ
|
||
</span>
|
||
)}
|
||
</h3>
|
||
|
||
{/* Bookmark Star Button */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleTrackTender(tender.id);
|
||
}}
|
||
title={tender.takipte ? 'Takibi Bırak' : 'Takibe Al'}
|
||
className={`p-1.5 rounded-lg transition-transform active:scale-95 ${
|
||
tender.takipte ? 'text-amber-400 bg-amber-50' : 'text-slate-300 hover:text-amber-400 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
<Star className={`w-5 h-5 ${tender.takipte ? 'fill-amber-400' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Subtitle Details */}
|
||
<div className="flex flex-wrap items-center gap-y-1 gap-x-3 text-xs text-slate-500 mt-1.5 font-medium">
|
||
<span className="flex items-center gap-1 text-slate-700 font-semibold truncate max-w-md">
|
||
<Building2 className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||
<span className="truncate">{tender.kurum}</span>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); toggleFavoriteKurum(tender.kurum); }}
|
||
title={isFavKurum ? 'Favori kurumdan çıkar' : 'Kurumu favorilere ekle'}
|
||
className="shrink-0 p-0.5"
|
||
>
|
||
<Star className={`w-3.5 h-3.5 ${isFavKurum ? 'text-amber-500 fill-amber-500' : 'text-slate-300 hover:text-amber-400'}`} />
|
||
</button>
|
||
</span>
|
||
<span className="flex items-center gap-1 text-slate-600">
|
||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0" />
|
||
<span>{tender.sehir}</span>
|
||
</span>
|
||
{ilanTarihiGoster && (
|
||
<span
|
||
className="bg-emerald-50 text-emerald-700 border border-emerald-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1"
|
||
title="İlanın yayımlandığı tarih"
|
||
>
|
||
<Megaphone className="w-3 h-3 text-emerald-600" />
|
||
<span>İlan: {ilanTarihiGoster}</span>
|
||
</span>
|
||
)}
|
||
{tender.tarih && (
|
||
<span
|
||
className="bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1"
|
||
title="İhalenin yapılacağı tarih"
|
||
>
|
||
<CalendarDays className="w-3 h-3 text-blue-600" />
|
||
<span>İhale: {tender.tarih}{tender.saat ? ` ${tender.saat}` : ''}</span>
|
||
</span>
|
||
)}
|
||
{tender.kalemSayisi > 0 && (
|
||
<span
|
||
className="bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1"
|
||
title="Teklif cetveli kalemleri — bir kullanıcının yüklediği ihale dokümanından çıkarıldı"
|
||
>
|
||
<Package className="w-3 h-3 text-indigo-600" />
|
||
<span>{tender.kalemSayisi} kalem</span>
|
||
</span>
|
||
)}
|
||
{tender.eklenmeZamani && !ilanTarihiGoster && (
|
||
<span className="bg-slate-100 text-slate-600 border border-slate-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1">
|
||
<Clock className="w-3 h-3 text-slate-500" />
|
||
<span>{tender.eklenmeZamani}</span>
|
||
</span>
|
||
)}
|
||
<span className="text-slate-400 font-mono text-[11px]">
|
||
# {tender.ikn}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Badges Row */}
|
||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||
<span className="bg-amber-500 text-white font-extrabold text-[11px] px-2.5 py-1 rounded-md shadow-2xs">
|
||
{tender.alimTuru}
|
||
</span>
|
||
<span className="bg-slate-100 text-slate-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-slate-200">
|
||
{tender.sektor}
|
||
</span>
|
||
{tender.cpvLabel && tender.cpvLabel !== 'Genel' && (
|
||
<span className="bg-sky-50 text-sky-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-sky-200">
|
||
{tender.cpvLabel}
|
||
</span>
|
||
)}
|
||
{tender.aciliyetBadge && (
|
||
<span className="bg-red-500 text-white font-extrabold text-[11px] px-2 py-1 rounded-md flex items-center gap-1 shadow-2xs">
|
||
<AlertTriangle className="w-3 h-3" />
|
||
<span>{tender.aciliyetBadge}</span>
|
||
</span>
|
||
)}
|
||
{tender.aiMatchScore != null && (
|
||
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||
<Sparkles className="w-3 h-3 text-blue-600" />
|
||
<span>%{tender.aiMatchScore} Uyumlu</span>
|
||
</span>
|
||
)}
|
||
{tender.kaynak && (
|
||
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-[11px] font-bold px-2 py-1 rounded-md">
|
||
🟢 {tender.kaynak}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Teklif cetveli kalemleri (ortak havuz) — firmanın kendi ürününü
|
||
şartnameyi indirmeden görebilmesi için kartta gösterilir. */}
|
||
{tender.kalemSayisi > 0 && (
|
||
<div
|
||
onClick={() => setActiveTenderDetail(tender)}
|
||
className="bg-indigo-50/60 hover:bg-indigo-50 rounded-lg p-2.5 border border-indigo-200 space-y-1.5 cursor-pointer transition-colors"
|
||
>
|
||
<p className="text-[10px] font-black uppercase tracking-wider text-indigo-700/70">
|
||
{eslesenKalemler ? 'Aramanızla eşleşen kalemler' : 'Teklif cetveli kalemleri'}
|
||
</p>
|
||
{(eslesenKalemler
|
||
? eslesenKalemler.slice(0, 3).map((k) => k)
|
||
: (tender.kalemler || []).slice(0, 3).map((ad) => ({ ad }))
|
||
).map((k, idx) => (
|
||
<div key={idx} className="flex items-center justify-between gap-2 text-xs font-semibold text-slate-700">
|
||
<span className="flex items-center gap-2 truncate">
|
||
<Package className="w-3.5 h-3.5 text-indigo-500 shrink-0" />
|
||
<span className="truncate">{k.ad}</span>
|
||
</span>
|
||
{k.miktar != null && (
|
||
<span className="bg-white border border-indigo-200 px-2 py-0.5 rounded text-[11px] font-bold text-slate-900 shrink-0">
|
||
{k.miktar.toLocaleString('tr-TR')} {k.birim || ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
{tender.kalemSayisi > 3 && (
|
||
<p className="text-[10px] font-bold text-indigo-600 hover:underline pt-0.5 text-right">
|
||
+ {tender.kalemSayisi - 3} kalem daha — tümünü gör
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Material Items Preview Box */}
|
||
{!tender.kalemSayisi && tender.items && tender.items.length > 0 && (
|
||
<div
|
||
onClick={() => setActiveTenderDetail(tender)}
|
||
className="bg-slate-50 hover:bg-slate-100/80 rounded-lg p-2.5 border border-slate-200 space-y-1.5 cursor-pointer transition-colors"
|
||
>
|
||
{tender.items.slice(0, 3).map((item, idx) => (
|
||
<div key={idx} className="flex items-center justify-between text-xs font-semibold text-slate-700">
|
||
<span className="flex items-center gap-2 truncate">
|
||
<Package className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
||
<span className="truncate">{item.ad}</span>
|
||
</span>
|
||
<span className="bg-white border border-slate-200 px-2 py-0.5 rounded text-[11px] font-bold text-slate-900 shrink-0">
|
||
{item.miktar} {item.birim || 'adet'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{tender.items.length > 3 && (
|
||
<p className="text-[10px] font-bold text-blue-600 hover:underline pt-0.5 text-right">
|
||
+ {tender.items.length - 3} malzeme kalemi daha...
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
};
|