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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user