Files
ihaleist/src/components/UrunAramaModal.jsx
T

329 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};