Files
ihaleist/src/components/TenderList.jsx
T

239 lines
13 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 {matchesRegion,matchesProcurement} from '../services/savedFilterCriteria';
import { matchesTenderSearch, matchesDetailedSearch } from '../services/tenderSearch';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../context/AppContext';
import { TenderCard } from './TenderCard';
import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react';
import { createTenderWorkbook } from '../services/tenderWorkbook';
import { getToken } from '../services/auth';
import { useDeadlineClock } from '../hooks/useDeadlineClock';
import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines, compareTenderPublications } from '../services/ihaleTarih';
// İl adları kaynaklar arasında farklı yazılabiliyor (Ankara / ANKARA);
// filtre büyük-küçük harf ve Türkçe karakter duyarlılığına takılmasın.
const normTr = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
export const TenderList = () => {
const {
tenders,
searchQuery,
detailedQueries,setDetailedQueries,
kalemEslesen, itemSearchState, retryItemSearch,
uyumEsigi,
selectedCity,selectedRegion,setSelectedRegion,
selectedAlimTuru,
selectedSektorler,
selectedCpvler,
selectedKurumTipi,
quickFilter,
onlyValidOnly,
setOnlyValidOnly,
favoriteKurumlar,
oncekiGiris
} = useApp();
const [sortBy, setSortBy] = useState('newest'); // 'newest', 'urgent', 'distant'
// Sonsuz kaydırma: sayfa yerine aşağı indikçe liste büyür.
// (Sayfalama 10 bin kayıtta 1000+ sayfa düğmesi basıyordu.)
const SAYFA_ADIM = 20;
const [gosterilen, setGosterilen] = useState(SAYFA_ADIM);
const dipRef = useRef(null);
const now = useDeadlineClock();
// Filter logic
const filteredTenders = useMemo(() => {
return tenders.filter(tender => {
// Search Query
if (!matchesTenderSearch(tender,searchQuery,kalemEslesen)) return false;
if (!matchesDetailedSearch(tender,detailedQueries)) return false;
// Geçmiş ihaleler: varsayılan olarak GİZLİ. Kullanıcı "geçmişi de göster"
// derse (onlyValidOnly=false) hepsi gelir. Tarihi okunamayan kayıt gizlenmez.
// Arşiv / sonuçlananlar gibi geçmişi hedefleyen hızlı filtrelerde kapı kapalı.
if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, now)) return false;
// Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris, uyumEsigi })) return false;
// Dropdown Filters
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
if (!matchesProcurement(tender,selectedAlimTuru)) return false;
if (!matchesRegion(tender,selectedRegion)) return false;
if (selectedSektorler.length && !selectedSektorler.includes(tender.sektor)) return false;
if (selectedCpvler.length && !(tender.cpvBolum || []).some(b => selectedCpvler.includes(b))) return false;
if (selectedKurumTipi && tender.kurumTipi !== selectedKurumTipi) return false;
return true;
}).sort((a, b) => {
if (sortBy === 'uyum') return (b.aiMatchScore ?? -1) - (a.aiMatchScore ?? -1) || compareTenderPublications(a,b);
if (sortBy === 'newest') return compareTenderPublications(a,b);
if (sortBy === 'urgent') return compareTenderDeadlines(a,b);
if (sortBy === 'distant') return compareTenderDeadlines(a,b,-1);
return 0;
});
}, [tenders, searchQuery, detailedQueries, kalemEslesen, uyumEsigi, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, now, favoriteKurumlar, oncekiGiris]);
const gorunenTenders = filteredTenders.slice(0, gosterilen);
const dahaVar = gosterilen < filteredTenders.length;
// Baştan başlama YALNIZCA filtre/sıralama değişiminde olmalı. filteredTenders
// dizisine bağlansaydı havuz her yenilendiğinde (10 dk) kullanıcı listenin
// başına fırlardı.
useEffect(() => { setGosterilen(SAYFA_ADIM); }, [
searchQuery, detailedQueries, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler,
selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly,
]);
const dahaGoster = useCallback(() => {
setGosterilen((n) => Math.min(n + SAYFA_ADIM, filteredTenders.length));
}, [filteredTenders.length]);
// Dipteki işaretçi görünür olunca yükle. IntersectionObserver yoksa
// (eski tarayıcı) aşağıdaki "Daha fazla göster" düğmesi devreye girer.
useEffect(() => {
const el = dipRef.current;
if (!el || !dahaVar || typeof IntersectionObserver === 'undefined') return;
const gozlemci = new IntersectionObserver(
(girisler) => { if (girisler[0]?.isIntersecting) dahaGoster(); },
{ rootMargin: '600px 0px' }, // ekrana girmeden önce yükle
);
gozlemci.observe(el);
return () => gozlemci.disconnect();
}, [dahaVar, dahaGoster]);
const [exporting,setExporting] = useState(false);
const [exportError,setExportError] = useState(null);
const [preparedExport,setPreparedExport] = useState(null);
const preparedUrl = useRef(null);
const exportPending = useRef(false);
const mounted = useRef(true);
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;if(preparedUrl.current)URL.revokeObjectURL(preparedUrl.current);};},[]);
const handleExportExcel = async () => {
if (exportPending.current) return;
exportPending.current = true; setExporting(true); setExportError(null);
if(preparedUrl.current) URL.revokeObjectURL(preparedUrl.current);
preparedUrl.current=null;setPreparedExport(null);
const session = getToken();
// Export the full filtered result at click time, including rows below the fold.
const selected = filteredTenders.map(t=>({...t}));
try {
const bytes = await createTenderWorkbook(selected);
if (!mounted.current || getToken() !== session) return;
const url = URL.createObjectURL(new Blob([bytes], {type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}));
preparedUrl.current=url;
setPreparedExport({url,name:`ihaleist_ihale_listesi_${Date.now()}.xlsx`,count:selected.length,
query:searchQuery.trim(),session,createdAt:new Date().toLocaleTimeString('tr-TR')});
} catch (error) { if (mounted.current && getToken() === session) setExportError(error.message); }
finally { exportPending.current=false; if (mounted.current) setExporting(false); }
};
return (
<div className="w-full min-w-0 flex-1 space-y-4">
{Object.entries(detailedQueries).filter(([,value])=>value).map(([key,value])=><div key={key} className="flex gap-3 items-center flex-wrap text-sm bg-blue-50 border rounded p-3">
<span>{key==='institution'?'Kurum adı':'İKN / DT no'}: {value}</span>
<button className="underline text-blue-800" onClick={()=>setDetailedQueries(old=>({...old,[key]:''}))}>{key==='institution'?'Kurum adı filtresini kaldır':'İKN filtresini kaldır'}</button>
</div>)}
{selectedRegion&&<div className="flex gap-3 items-center flex-wrap text-sm bg-blue-50 border rounded p-3"><span>İl bazında bölge filtresi: {selectedRegion}</span><button className="underline text-blue-800" onClick={()=>setSelectedRegion('')}>Bölge filtresini kaldır</button></div>}
{/* Header bar above list */}
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-blue-600" />
<h2 className="font-extrabold text-base text-slate-900">
İhale Listesi <span className="text-slate-500 font-medium text-xs">({filteredTenders.length.toLocaleString('tr-TR')} Sonuç)</span>
</h2>
</div>
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
{/* Varsayılan: yalnızca ihale tarihi geçmemiş ilanlar */}
<label
className="flex items-center gap-1.5 text-xs font-semibold text-slate-700 cursor-pointer select-none whitespace-nowrap"
title="Süresi geçtiği bilinen, iptal edilen ve sonuçlanan kayıtlar gizlenir. Tarihi veya saati bilinmeyen kayıtlar ayrıca doğrulanmalıdır."
>
<input
type="checkbox"
checked={onlyValidOnly}
onChange={(e) => setOnlyValidOnly(e.target.checked)}
className="accent-blue-600"
/>
Süresi geçenleri gizle
</label>
<select aria-label="İhale sıralaması"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="bg-slate-50 border border-slate-300 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-500"
>
<option value="uyum">Şirketime En Uyumlu</option>
<option value="newest">Yayın Tarihi: En Yeni</option>
<option value="urgent">Tarihi Yaklaşanlar (Acil)</option>
<option value="distant">Tarihi Uzak Olanlar</option>
</select>
<button
onClick={handleExportExcel} disabled={exporting || !filteredTenders.length}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3.5 py-2 rounded-lg flex items-center gap-1.5 transition-colors shadow-xs shrink-0"
>
<Download className="w-4 h-4" />
<span>{exporting ? 'Excel hazırlanıyor…' : 'Excel Hazırla'}</span>
</button>
</div>
</div>
{sortBy === 'newest' && <p className="text-xs text-slate-500">Yayın tarihi bilinmeyen kayıtlar sonda gösterilir. Aynı gün içinde saati bilinen kayıtlar önce gelir.</p>}
{itemSearchState?.status === 'loading' && <p role="status" className="text-xs text-slate-600">Doküman kalemlerinde arama sürüyor; listedeki sonuçlar henüz genişleyebilir.</p>}
{itemSearchState?.status === 'error' && <div role="alert" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p>{itemSearchState.error} Başlık, kurum ve yüklü kart bilgileriyle arama sürüyor; sonuçlar eksik olabilir.</p>
<button type="button" onClick={retryItemSearch} className="mt-1 underline font-semibold">Kalem aramasını yeniden dene</button>
</div>}
{preparedExport && preparedExport.session === getToken() && <div role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs space-y-1">
<p>{preparedExport.createdAt} itibarıyla {preparedExport.count} ihale için Excel hazır{preparedExport.query ? ` — arama: ${preparedExport.query}` : ''}.</p>
<a href={preparedExport.url} download={preparedExport.name} className="font-bold text-emerald-800 underline">Excel dosyasını indir — {preparedExport.name}</a>
</div>}
{exportError && <p role="alert" className="text-sm text-red-700">{exportError}</p>}
{/* Tender Cards List */}
{gorunenTenders.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-xs">
<SlidersHorizontal className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<h3 className="text-base font-bold text-slate-800">Arama kriterlerine uygun ihale bulunamadı</h3>
<p className="text-xs text-slate-500 mt-1">Lütfen arama kelimenizi değiştirin veya filtreleri temizleyin.</p>
</div>
) : (
<div className="space-y-3.5">
{gorunenTenders.map(tender => (
<TenderCard key={tender.id} tender={tender} />
))}
</div>
)}
{/* Sonsuz kaydırma dibi — sayfa yok, aşağı indikçe dolar */}
{filteredTenders.length > 0 && (
<div ref={dipRef} className="py-4 text-center">
{dahaVar ? (
<>
<div className="flex items-center justify-center gap-2 text-xs font-medium text-slate-500">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
{gorunenTenders.length.toLocaleString('tr-TR')} / {filteredTenders.length.toLocaleString('tr-TR')} ihale gösteriliyor
</div>
{/* Gözlemci çalışmazsa elle yükleme yolu açık kalsın */}
<button
onClick={dahaGoster}
className="mt-2 text-xs font-bold text-blue-600 hover:text-blue-700 hover:underline"
>
Daha fazla göster
</button>
</>
) : (
<p className="text-xs font-medium text-slate-400">
Tüm kayıtlar gösterildi — {filteredTenders.length.toLocaleString('tr-TR')} ihale
</p>
)}
</div>
)}
</div>
);
};