329 lines
16 KiB
React
329 lines
16 KiB
React
import { deadlineInfo } from '../services/tenderCalendar';
|
||
import React, { useState, useEffect, useRef } 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';
|
||
import { okasAra } from '../services/kalem';
|
||
|
||
/**
|
||
* Ü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 [tarih, saat] = String(tarihSaat || '').trim().split(/\s+/);
|
||
return deadlineInfo({tarih,saat}).expired === true;
|
||
};
|
||
|
||
export const UrunAramaModal = () => {
|
||
const {
|
||
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
|
||
urunAramaBaslangic, setUrunAramaBaslangic,
|
||
} = useApp();
|
||
const requestVersion = useRef(0);
|
||
const [submitted, setSubmitted] = useState(null);
|
||
useEffect(() => {
|
||
if (!isUrunAramaOpen) {
|
||
requestVersion.current++;
|
||
setDurum((d) => ({ ...d, yukleniyor: false }));
|
||
}
|
||
}, [isUrunAramaOpen]);
|
||
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 });
|
||
// OKAS modu: idarenin kendi sınıflandırma koduyla arama. Serbest metinle
|
||
// bulunamayan ("ortopedi" başlıkta hiç geçmiyor) ihaleleri kod yakalar.
|
||
const [mod, setMod] = useState('metin'); // 'metin' | 'okas'
|
||
const [okasQ, setOkasQ] = useState('');
|
||
const [okasAdaylar, setOkasAdaylar] = useState([]);
|
||
const [okasSecili, setOkasSecili] = useState([]); // [{kod, ad}]
|
||
|
||
// Sol menüden bir OKAS koduyla açıldıysa o kodla başla ve hemen ara
|
||
useEffect(() => {
|
||
if (!isUrunAramaOpen || !urunAramaBaslangic?.okasKod) return;
|
||
setMod('okas');
|
||
setOkasSecili([{ kod: urunAramaBaslangic.okasKod, ad: urunAramaBaslangic.okasAd || '' }]);
|
||
setUrunAramaBaslangic(null);
|
||
araOkas([urunAramaBaslangic.okasKod], 0);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isUrunAramaOpen, urunAramaBaslangic]);
|
||
|
||
// OKAS adı yazdıkça katalogda ara (350 ms)
|
||
useEffect(() => {
|
||
const t = okasQ.trim();
|
||
if (mod !== 'okas' || t.length < 2) { setOkasAdaylar([]); return; }
|
||
let iptal = false;
|
||
const z = setTimeout(() => okasAra(t).then((k) => { if (!iptal) setOkasAdaylar(k); }).catch(() => {}), 350);
|
||
return () => { iptal = true; clearTimeout(z); };
|
||
}, [okasQ, mod]);
|
||
|
||
if (!isUrunAramaOpen) return null;
|
||
|
||
const sorgula = async (query, sayfa = 0) => {
|
||
const request = ++requestVersion.current;
|
||
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
|
||
try {
|
||
const d = await urunAra(query.mod === 'metin' ? query.label : '', {
|
||
skip: sayfa * SAYFA, take: SAYFA,
|
||
teknik: query.teknik, okas: query.kodlar || [],
|
||
});
|
||
if (request !== requestVersion.current) return;
|
||
setSubmitted(query);
|
||
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
|
||
} catch (e) {
|
||
if (request !== requestVersion.current) return;
|
||
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
|
||
}
|
||
};
|
||
|
||
const araOkas = (kodlar, sayfa = 0) => {
|
||
if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; }
|
||
return sorgula({ mod: 'okas', label: kodlar.join(', '), kodlar: [...kodlar], teknik: false }, sayfa);
|
||
};
|
||
|
||
const ara = () => {
|
||
if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod));
|
||
const terim = q.trim();
|
||
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
|
||
return sorgula({ mod: 'metin', label: terim, teknik });
|
||
};
|
||
|
||
const sorguDegisti = submitted && (submitted.mod !== mod ||
|
||
(mod === 'metin'
|
||
? submitted.label !== q.trim() || submitted.teknik !== teknik
|
||
: submitted.label !== okasSecili.map((k) => k.kod).join(', ')));
|
||
|
||
const okasEkle = (k) => {
|
||
if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]);
|
||
setOkasQ(''); setOkasAdaylar([]);
|
||
};
|
||
|
||
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 aria-label="Ürün aramasını kapat" 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="inline-flex items-center gap-1 bg-slate-100 border border-slate-200 rounded-xl p-1">
|
||
{[['metin', 'Ürün adıyla'], ['okas', 'OKAS koduyla']].map(([m, ad]) => (
|
||
<button key={m} type="button" onClick={() => setMod(m)}
|
||
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors ${
|
||
mod === m ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-white'}`}>
|
||
{ad}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{mod === 'okas' && (
|
||
<div className="space-y-2">
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
value={okasQ}
|
||
onChange={(e) => setOkasQ(e.target.value)}
|
||
placeholder="OKAS adı yazın — örn. ortopedik, röntgen, eldiven…"
|
||
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"
|
||
/>
|
||
{okasAdaylar.length > 0 && (
|
||
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-xl shadow-lg max-h-56 overflow-y-auto">
|
||
{okasAdaylar.map((k) => (
|
||
<button key={k.kod} type="button" onClick={() => okasEkle(k)}
|
||
className="w-full text-left px-3 py-2 text-xs hover:bg-indigo-50 flex items-baseline gap-2">
|
||
<span className="font-mono font-bold text-indigo-700 shrink-0">{k.kod}</span>
|
||
<span className="text-slate-800 truncate">{k.ad}</span>
|
||
{k.altSayisi > 0 && <span className="text-[10px] text-slate-400 shrink-0">+{k.altSayisi} alt</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{okasSecili.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{okasSecili.map((k) => (
|
||
<span key={k.kod} className="inline-flex items-center gap-1.5 bg-indigo-50 border border-indigo-200 text-indigo-800 text-[11px] font-bold px-2 py-1 rounded-lg">
|
||
<span className="font-mono">{k.kod}</span> {k.ad}
|
||
<button type="button" onClick={() => setOkasSecili((o) => o.filter((x) => x.kod !== k.kod))}
|
||
className="text-indigo-400 hover:text-red-600" aria-label="kaldır">✕</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
{mod === 'metin' && (
|
||
<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>
|
||
)}
|
||
{mod === 'okas' && <div className="flex-1" />}
|
||
<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">
|
||
{mod === 'metin' && (
|
||
<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" />
|
||
Süresi geçtiği bilinenleri gizle
|
||
</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 && sorguDegisti && (
|
||
<p role="status" className="text-xs text-amber-800">Arama alanları değişti. Aşağıdaki sonuçlar ve sayfalar önceki aramaya aittir. Yeni seçimler için Ara düğmesine basın.</p>
|
||
)}
|
||
|
||
{sonuc && (
|
||
<>
|
||
<div className="flex items-baseline justify-between gap-2 flex-wrap">
|
||
<p className="text-xs font-bold text-slate-900">
|
||
{submitted?.mod === 'okas'
|
||
? <>OKAS <span className="text-indigo-700">{submitted?.label}</span> kodlu{' '}</>
|
||
: <>Teklif cetvelinde <span className="text-indigo-700">"{submitted?.label}"</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ş. "Süresi geçtiği bilinenleri gizle" 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={() => sorgula(submitted, 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={() => sorgula(submitted, 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>
|
||
);
|
||
};
|