Anlamsal bonus: profil metni sunucuda gomulur (multilingual-e5-small, ONNX q8, @huggingface/transformers — Python/GPU yok), havuzdaki her ihaleyle kosinus alinir, havuz ici z-skor → +0..20 puan. Sozluksel skor ana eksen kalir; bonus eklenir. Sozluksel 0 iken bonus yalniz ≥10'sa gorunur. NEDEN BONUS, ESIK DEGIL (olculdu): e5 kosinusleri 0,79-0,86 bandina sikisiyor (havuz ort 0,814 sd 0,026); kirtasiye ile rontgen arasi 0,06. kaanvp/kobi-ihale-eslestirme'nin 0,65 esigiyle trafo bakimi da "eslesti" sayilirdi. Ama siralama dogru ve sozlukselin kacirdigini yakaliyor: "GENEL TIBBI SARF" profilde birebir terim yokken kirtasiyenin ustune cikiyor. - server/uyum-gomme.mjs: acilista arka planda isitma (~45 sn soguk), ihale gommeleri bellekte (metin imzasi degisince yenilenir), model data/modeller volume'unda bir kez indirilir; paket/yukleme yoksa sessizce kapali - POST /api/uyum/gomme, GET /api/health/uyum - Dockerfile: calisma imajina @huggingface/transformers@3 (package.json'a degil — sunucu sifir-bagimlilik, calisma katmani ayri npm i) - Olculen: 3.2 bin ihale ilk cagri 7,6 sn (tumu gomuluyor), ikinci 17 ms; +20 alan 170, ≥10 alan 610 ihale Firma esigi: profilde uyumEsigi (varsayilan 60, kalici); "Uyumlu" hizli filtresi bu esigi kullanir; profil modalinda kaydirici + bonus durumu. Modaldaki "%98+ dogruluk" cumlesi kaldirildi — olculmemis iddiaydi. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
226 lines
10 KiB
React
226 lines
10 KiB
React
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 confetti from 'canvas-confetti';
|
||
import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER } 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,
|
||
kalemEslesen,
|
||
uyumEsigi,
|
||
selectedCity,
|
||
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);
|
||
|
||
// Bugünün başlangıcı — "bugün ihalesi olan" listede kalsın.
|
||
const bugun = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }, []);
|
||
|
||
// Filter logic
|
||
const filteredTenders = useMemo(() => {
|
||
return tenders.filter(tender => {
|
||
// Search Query
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
const matchTitle = (tender.baslik || '').toLowerCase().includes(q);
|
||
const matchKurum = (tender.kurum || '').toLowerCase().includes(q);
|
||
const matchIkn = (tender.ikn || '').toLowerCase().includes(q);
|
||
const matchItems = tender.items && tender.items.some(i => (i.ad || '').toLowerCase().includes(q));
|
||
// Kartta taşınan ilk kalemler + sunucudaki tam kalem araması
|
||
const matchKalem = tender.kalemler && tender.kalemler.some(a => (a || '').toLowerCase().includes(q));
|
||
const matchHavuz = kalemEslesen && tender.ikn && kalemEslesen.has(tender.ikn);
|
||
if (!matchTitle && !matchKurum && !matchIkn && !matchItems && !matchKalem && !matchHavuz) 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, bugun)) 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 (selectedAlimTuru && tender.alimTuru !== selectedAlimTuru) 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) || b.id.localeCompare(a.id);
|
||
if (sortBy === 'newest') return b.id.localeCompare(a.id);
|
||
if (sortBy === 'urgent') return a.tarih.localeCompare(b.tarih);
|
||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||
return 0;
|
||
});
|
||
}, [tenders, searchQuery, kalemEslesen, uyumEsigi, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, 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, selectedCity, 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 handleExportExcel = () => {
|
||
confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } });
|
||
|
||
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor', 'CPV'];
|
||
const rows = filteredTenders.map(t => [
|
||
t.ikn,
|
||
`"${t.baslik.replace(/"/g, '""')}"`,
|
||
`"${t.kurum.replace(/"/g, '""')}"`,
|
||
t.sehir,
|
||
t.tarih,
|
||
t.alimTuru,
|
||
t.sektor,
|
||
`"${(t.cpvLabel || (t.cpv || []).join(', ') || '').replace(/"/g, '""')}"`
|
||
]);
|
||
|
||
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||
const encodedUri = encodeURI(csvContent);
|
||
const link = document.createElement('a');
|
||
link.setAttribute('href', encodedUri);
|
||
link.setAttribute('download', `ihaleist_ihale_listesi_${Date.now()}.csv`);
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
return (
|
||
<div className="flex-1 space-y-4">
|
||
|
||
{/* 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 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="Kapalı: ihale tarihi geçmiş ilanlar da listelenir"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyValidOnly}
|
||
onChange={(e) => setOnlyValidOnly(e.target.checked)}
|
||
className="accent-blue-600"
|
||
/>
|
||
Sadece açık ihaleler
|
||
</label>
|
||
|
||
<select
|
||
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">En Yeni Eklenenler</option>
|
||
<option value="urgent">Tarihi Yaklaşanlar (Acil)</option>
|
||
<option value="distant">Tarihi Uzak Olanlar</option>
|
||
</select>
|
||
|
||
<button
|
||
onClick={handleExportExcel}
|
||
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>Excel İndir</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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>
|
||
);
|
||
};
|