feat: ortak kalem havuzu — teklif cetveli kalemleri kartta gorunur ve aranabilir

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>
This commit is contained in:
2026-09-03 11:51:39 +03:00
co-authored by Claude Opus 5
parent b41bc338af
commit 8826ff8e68
10 changed files with 650 additions and 8 deletions
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { Package, Loader2, Search } from 'lucide-react';
import { kalemleriGetir } from '../services/kalem';
/**
* Teklif cetveli kalemleri — ortak havuzdan.
*
* Kalemler EKAP API'sinde yok; doküman sayfası CAPTCHA ile korunduğu için
* sunucu otomatik indiremiyor. Bir kullanıcı ihale dokümanını yüklediğinde
* kalemler ayrıştırılıp havuza yazılıyor ve bu liste herkese görünür oluyor.
*/
export const TeklifCetveliKalemleri = ({ ikn }) => {
const [durum, setDurum] = useState({ yukleniyor: true, kalemler: [], kaynak: null });
const [suzgec, setSuzgec] = useState('');
useEffect(() => {
if (!ikn) { setDurum({ yukleniyor: false, kalemler: [], kaynak: null }); return; }
let iptal = false;
setDurum((d) => ({ ...d, yukleniyor: true }));
kalemleriGetir(ikn)
.then((d) => { if (!iptal) setDurum({ yukleniyor: false, kalemler: d.kalemler || [], kaynak: d.kaynak }); })
.catch(() => { if (!iptal) setDurum({ yukleniyor: false, kalemler: [], kaynak: null }); });
return () => { iptal = true; };
}, [ikn]);
if (durum.yukleniyor) {
return (
<p className="text-xs text-slate-500 flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Teklif cetveli kalemleri okunuyor…
</p>
);
}
if (!durum.kalemler.length) {
return (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-2">
<Package className="w-4 h-4 text-indigo-600" /> Teklif Cetveli Kalemleri
</h3>
<p className="text-xs text-slate-600 leading-relaxed mt-1.5">
Bu ihalenin kalem listesi henüz havuzda yok. Kalemler EKAP'ın API'sinde yayımlanmıyor;
yalnızca ihale dokümanının içinde bulunuyor. Aşağıdan dokümanı yüklerseniz kalemler
otomatik çıkarılır ve bu ihale <strong>tüm kullanıcılarda</strong> kalemleriyle listelenir.
</p>
</div>
);
}
const q = suzgec.trim().toLocaleLowerCase('tr-TR');
const gorunen = q
? durum.kalemler.filter((k) => (k.ad || '').toLocaleLowerCase('tr-TR').includes(q))
: durum.kalemler;
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-2">
<Package className="w-4 h-4 text-indigo-600" />
Teklif Cetveli Kalemleri ({durum.kalemler.length})
</h3>
<label className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
value={suzgec}
onChange={(e) => setSuzgec(e.target.value)}
placeholder="Kalem ara…"
className="text-xs pl-8 pr-3 py-1.5 rounded-lg border border-slate-300 w-44 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</label>
</div>
<div className="border border-slate-200 rounded-xl overflow-hidden shadow-2xs max-h-96 overflow-y-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200 sticky top-0">
<tr>
<th className="p-2.5 w-12">#</th>
<th className="p-2.5">Kalem adı</th>
<th className="p-2.5 text-right w-32">Miktar</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{gorunen.map((k, i) => (
<tr key={i} className="hover:bg-indigo-50/40">
<td className="p-2.5 font-bold text-slate-400">{k.sira ?? i + 1}</td>
<td className="p-2.5 font-semibold text-slate-800">{k.ad}</td>
<td className="p-2.5 text-right font-bold text-slate-900 whitespace-nowrap">
{k.miktar != null ? k.miktar.toLocaleString('tr-TR') : '—'} {k.birim || ''}
</td>
</tr>
))}
{gorunen.length === 0 && (
<tr><td colSpan={3} className="p-3 text-center text-slate-400">Bu aramayla eşleşen kalem yok.</td></tr>
)}
</tbody>
</table>
</div>
{durum.kaynak?.dosya && (
<p className="text-[10px] text-slate-400">
Kaynak: <strong>{durum.kaynak.dosya}</strong> — bir kullanıcının yüklediği ihale dokümanından
otomatik çıkarıldı. Teklif vermeden önce şartnamenin aslıyla karşılaştırın.
</p>
)}
</div>
);
};