feat: urun arama — urun adini ihale DOKUMANLARININ icinde aratir

saidsurucu/ihale-mcp deposu incelenirken EKAP arama API'sinin doküman ici
arama parametreleri oldugu goruldu: teklifCetvelindeAra, teknikSartnamedeAra,
idariSartnamedeAra, sozlesmeTasarisindaAra. EKAP dokumani kendi tarafinda
indeksliyor — yani teklif cetvelinde arama icin dokumani indirmeye (ve
CAPTCHA'ya) gerek yok.

Kullanicinin sikayetini olcen kanit:
  ELDIVEN         basliкta  1.135  → teklif cetvelinde 29.057
  ORTOPEDIK VIDA  baslikta      0  → teklif cetvelinde      3
  HEMOGRAM        baslikta    283  → teklif cetvelinde  2.529
"ORTOPEDIK VIDA" hicbir ihale BASLIGINDA gecmiyor; gectigi ihalelerin adi
"VETERINER ILAC ... SARF MALZEME". Basliga bakan firma bunlari bulamiyordu.

- ekap-v2.mjs: searchBody artik opts.kapsam ile daraltilabiliyor;
  searchInDocuments() yalnizca teklif cetvelinde (istege bagli teknik
  sartnamede) arar ve sonucu normalize eder
- GET /api/urun-ara?q=&skip=&take=&teknik= — 5 dk bellek onbellegi ile
  (EKAP'a uzak cagri, ayni sorgu kaynagi yormasin)
- Header'da "URUN ARA" dugmesi + UrunAramaModal: sayfalama, "sadece teklif
  verilebilir" suzgeci, havuzda acik olan ihalede detayi acma kisayolu

SINIR: EKAP hangi kalemin eslestigini dondurmuyor, yalnizca eslesen ihaleleri.
Kalem dokumu icin dokuman yuklemeye dayali ortak havuz yolu gecerli kaliyor;
arayuzde bu acikca yaziliyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:55:36 +03:00
co-authored by Claude Opus 5
parent 8826ff8e68
commit ff797f5a91
7 changed files with 309 additions and 1 deletions
+207
View File
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import {
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
} from 'lucide-react';
import { urunAra } from '../services/urunArama';
/**
* Ürün Arama — ürün adını ihale dokümanlarının içinde aratır.
*
* İhalelerin sadece başlığına bakmak yetersiz: "ORTOPEDİK VİDA" hiçbir
* başlıkta geçmiyor ama üç ihalenin teklif cetvelinde var, ve o ihalelerin
* adı "VETERİNER İLAÇ ... SARF MALZEME". Bu ekran o boşluğu kapatıyor.
*/
const SAYFA = 20;
const gecmisMi = (tarihSaat) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(String(tarihSaat || ''));
if (!m) return false;
const [, g, a, y, ss = '23', dd = '59'] = m;
return new Date(+y, +a - 1, +g, +ss, +dd) < new Date();
};
export const UrunAramaModal = () => {
const { isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders } = useApp();
const [q, setQ] = useState('');
const [teknik, setTeknik] = useState(false);
const [sadeceAcik, setSadeceAcik] = useState(true);
const [durum, setDurum] = useState({ yukleniyor: false, sonuc: null, hata: null, sayfa: 0 });
if (!isUrunAramaOpen) return null;
const ara = async (sayfa = 0) => {
const terim = q.trim();
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra(terim, { skip: sayfa * SAYFA, take: SAYFA, teknik });
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
};
const sonuc = durum.sonuc;
const liste = (sonuc?.list || []).filter((t) => !sadeceAcik || !gecmisMi(t.ihaleTarihSaat));
const gizlenen = (sonuc?.list?.length || 0) - liste.length;
// Havuzdaki açık ihaleyle eşleşirse kartı doğrudan açabilelim
const havuzda = (ikn) => tenders?.find((t) => t.ikn === ikn) || null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[92vh]">
<div className="bg-linear-to-r from-indigo-900 via-blue-900 to-slate-900 text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md">
<Package className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-base">Ürün Arama</h2>
<p className="text-xs text-blue-200">
Ürün adınızı ihale <strong>dokümanlarının içinde</strong> arar — başlıkta geçmese bile bulur.
</p>
</div>
</div>
<button onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<form onSubmit={(e) => { e.preventDefault(); ara(0); }} className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Örn. ortopedik vida, hemogram kiti, kurşun önlük…"
className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<button
type="submit"
disabled={durum.yukleniyor}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-bold text-xs px-5 rounded-xl flex items-center gap-2"
>
{durum.yukleniyor ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Ara
</button>
</div>
<div className="flex items-center gap-4 flex-wrap text-xs">
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={teknik} onChange={(e) => setTeknik(e.target.checked)} className="accent-indigo-600" />
Teknik şartnamede de ara
</label>
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
Sadece teklif verilebilir ihaleler
</label>
</div>
</form>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 flex gap-2.5">
<Info className="w-4 h-4 text-blue-700 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-900 leading-relaxed">
Arama EKAP'ın doküman dizininde yapılır; hangi kalemin eşleştiğini EKAP döndürmüyor,
yalnızca eşleşen ihaleleri veriyor. Kalem dökümünü görmek için ihalenin dokümanını
yükleyin — kalemler çıkarılıp herkese açılır.
</p>
</div>
{durum.hata && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 flex gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<p className="text-xs text-red-800">{durum.hata}</p>
</div>
)}
{sonuc && (
<>
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<p className="text-xs font-bold text-slate-900">
Teklif cetvelinde <span className="text-indigo-700">"{q.trim()}"</span> geçen{' '}
<span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu
</p>
{gizlenen > 0 && (
<p className="text-[11px] text-slate-500">{gizlenen} tanesi tarihi geçtiği için gizlendi</p>
)}
</div>
<div className="space-y-2">
{liste.map((t) => {
const acik = havuzda(t.ikn);
return (
<div key={t.ikn} className="border border-slate-200 rounded-xl p-3 hover:border-indigo-300 hover:bg-indigo-50/30 transition-colors">
<div className="flex items-start justify-between gap-3 flex-wrap">
<p className="font-bold text-xs text-slate-900 flex-1 min-w-0 leading-snug">{t.baslik}</p>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] font-bold bg-slate-100 text-slate-700 px-2 py-0.5 rounded">{t.tur}</span>
<span className="text-[10px] font-mono text-slate-500">{t.ikn}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600 mt-1.5">
<span className="flex items-center gap-1 truncate max-w-sm">
<Building2 className="w-3 h-3 text-slate-400 shrink-0" />
<span className="truncate">{t.kurum}</span>
</span>
{t.sehir && <span className="flex items-center gap-1"><MapPin className="w-3 h-3 text-rose-500" />{t.sehir}</span>}
{t.ihaleTarihSaat && (
<span className="flex items-center gap-1"><CalendarDays className="w-3 h-3 text-blue-600" />{t.ihaleTarihSaat}</span>
)}
<span className="text-slate-400">{t.durum}</span>
{acik && (
<button
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
className="font-bold text-indigo-700 hover:underline"
>
Havuzda açık — detayı aç
</button>
)}
</div>
</div>
);
})}
{liste.length === 0 && (
<p className="text-xs text-slate-500 text-center py-6">
{sonuc.totalCount > 0
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Sadece teklif verilebilir" kutusunu kapatarak görebilirsiniz.'
: 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'}
</p>
)}
</div>
{sonuc.totalCount > SAYFA && (
<div className="flex items-center justify-center gap-3 pt-2">
<button
disabled={durum.sayfa === 0 || durum.yukleniyor}
onClick={() => ara(durum.sayfa - 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
← Önceki
</button>
<span className="text-xs text-slate-500">
{durum.sayfa + 1} / {Math.ceil(sonuc.totalCount / SAYFA).toLocaleString('tr-TR')}
</span>
<button
disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor}
onClick={() => ara(durum.sayfa + 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
Sonraki →
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};