CPV/OKAS ham kod sınıflandırması: yayınlanmadan önce tamamlanan bir gate + sidebar filtresi

- server/cpv-classify.mjs (yeni): existing (kalem kodu) → keyword (66 kural, 45 OKAS_BOLUM kodunun tamamı kapsanıyor) → AI (analyzeSpec, sadece sözlükte gerçekten var olan kod kabul edilir) hibrit sınıflandırma
- server/index.js: cache.tenders'a eklenmeden ÖNCE her ihale için CPV sınıflandırması await'leniyor (mevcut "sektör" alanının aksine, sonradan zenginleştirilmiyor) — hem ilk yükleme hem arka plan tamamlama yolunda
- İKN bazlı kalıcı SQLite önbellek (history-store.mjs) — aynı ilan bir daha asla yeniden sınıflandırılmıyor
- /api/meta/filtreler artık cpvBolumler döndürüyor
- Frontend: Sektörler'in hemen altına CPV Kodu filtresi (arama + çoklu seçim), ihale kartında CPV rozeti, landing page'de yeni özellik: "AB uyumlu CPV sınıflandırması — Türkiye'de ilk"
This commit is contained in:
2026-08-30 14:00:56 +03:00
parent f98568da8e
commit c5662d2e60
10 changed files with 490 additions and 15 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import {
ArrowRight, Bell, Building2, CalendarDays, ChartNoAxesCombined, CheckCircle2,
FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Timer, TrendingUp, Zap,
FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Tag, Timer, TrendingUp, Zap,
} from 'lucide-react';
/* ---------- yardımcılar ---------- */
@@ -62,6 +62,7 @@ const OZELLIKLER = [
{ icon: Radar, baslik: 'Rakip radarı', metin: 'Hangi firma hangi ihaleyi, hangi bedelle kazandı? Rakiplerinizin izini sürün, stratejinizi veriyle kurun.', renk: 'from-rose-400/20 to-rose-400/0', ikon: 'text-rose-300' },
{ icon: TrendingUp, baslik: 'Fiyat & piyasa analizi', metin: 'Geçmiş sonuçlardan yaklaşık maliyet ve kırım oranı analizi. Teklifinizi tahminle değil, veriyle verin.', renk: 'from-emerald-400/20 to-emerald-400/0', ikon: 'text-emerald-300' },
{ icon: Bell, baslik: 'Günlük bülten & uyarılar', metin: 'Kriterlerinize uyan yeni ilanlar her sabah e-postanızda. Son teklif tarihi yaklaşınca otomatik hatırlatma.', renk: 'from-sky-400/20 to-sky-400/0', ikon: 'text-sky-300' },
{ icon: Tag, baslik: 'AB uyumlu CPV sınıflandırması', metin: 'Türkiye\'de bir ilk: her ihale, Avrupa Birliği standardı CPV koduna göre otomatik sınıflandırılır. Sektörünüze uyan ihaleleri AB standardında filtreleyin.', renk: 'from-teal-400/20 to-teal-400/0', ikon: 'text-teal-300' },
];
/* ---------- bileşenler ---------- */
+62 -2
View File
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext';
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
import {
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star, Hash
} from 'lucide-react';
export const LeftSidebar = () => {
@@ -20,6 +20,10 @@ export const LeftSidebar = () => {
setSelectedSektorler,
toggleSektor,
sektorListesi,
selectedCpvler,
setSelectedCpvler,
toggleCpv,
cpvBolumListesi,
selectedKurumTipi,
setSelectedKurumTipi,
selectedCity,
@@ -28,9 +32,11 @@ export const LeftSidebar = () => {
} = useApp();
const [sektorAra, setSektorAra] = useState('');
const [cpvAra, setCpvAra] = useState('');
const [openSections, setOpenSections] = useState({
alimTuru: true,
sektorler: true,
cpv: false,
kurumTipleri: false,
iller: false
});
@@ -62,7 +68,7 @@ export const LeftSidebar = () => {
<span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
</span>
{(quickFilter !== 'all' || selectedSektorler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
{(quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
<button
onClick={clearAllFilters}
className="text-[11px] font-medium text-blue-600 hover:underline"
@@ -242,6 +248,60 @@ export const LeftSidebar = () => {
)}
</div>
{/* CPV Kodu */}
<div>
<button
onClick={() => toggleSection('cpv')}
className="w-full px-4 py-3 flex items-center justify-between text-xs font-bold text-slate-800 bg-slate-50/50 hover:bg-slate-100 transition-colors"
>
<span className="flex items-center gap-2">
<Hash className="w-4 h-4 text-blue-600" /> CPV Kodu
</span>
{openSections.cpv ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
</button>
{openSections.cpv && (
<div className="p-3 space-y-1 text-xs">
<p className="text-[11px] text-slate-500 mb-2 leading-relaxed">
CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır.
</p>
<input
value={cpvAra}
onChange={(e) => setCpvAra(e.target.value)}
placeholder="CPV ara…"
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 mb-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
/>
<div className="max-h-56 overflow-y-auto space-y-1">
<button
onClick={() => { setSelectedCpvler([]); setActiveTab('list'); }}
className={`w-full text-left px-2 py-1 rounded font-medium ${!selectedCpvler.length ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
>
Tüm CPV Kodları {selectedCpvler.length > 0 && <span className="text-slate-400">({selectedCpvler.length} seçili)</span>}
</button>
{/* Çoklu seçim: kutucuklar birikir, liste kapanmaz */}
{cpvBolumListesi
.filter((c) => c.kod.includes(cpvAra) || c.etiket.toLocaleLowerCase('tr').includes(cpvAra.toLocaleLowerCase('tr')))
.map(c => {
const secili = selectedCpvler.includes(c.kod);
return (
<label
key={c.kod}
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer font-medium ${secili ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
>
<input
type="checkbox"
checked={secili}
onChange={() => { toggleCpv(c.kod); setActiveTab('list'); }}
className="accent-blue-600 shrink-0"
/>
<span className="truncate">{c.kod} — {c.etiket}</span>
</label>
);
})}
</div>
</div>
)}
</div>
{/* Kurum Tipleri */}
<div>
<button
+5
View File
@@ -93,6 +93,11 @@ export const TenderCard = ({ tender }) => {
<span className="bg-slate-100 text-slate-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-slate-200">
{tender.sektor}
</span>
{tender.cpvLabel && tender.cpvLabel !== 'Genel' && (
<span className="bg-sky-50 text-sky-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-sky-200">
{tender.cpvLabel}
</span>
)}
{tender.aciliyetBadge && (
<span className="bg-red-500 text-white font-extrabold text-[11px] px-2 py-1 rounded-md flex items-center gap-1 shadow-2xs">
<AlertTriangle className="w-3 h-3" />
+6 -3
View File
@@ -15,6 +15,7 @@ export const TenderList = () => {
selectedCity,
selectedAlimTuru,
selectedSektorler,
selectedCpvler,
selectedKurumTipi,
quickFilter,
onlyValidOnly,
@@ -53,6 +54,7 @@ export const TenderList = () => {
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;
@@ -62,7 +64,7 @@ export const TenderList = () => {
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
return 0;
});
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedKurumTipi, quickFilter, sortBy]);
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy]);
const totalPages = Math.max(1, Math.ceil(filteredTenders.length / itemsPerPage));
const paginatedTenders = filteredTenders.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
@@ -70,7 +72,7 @@ export const TenderList = () => {
const handleExportExcel = () => {
confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } });
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor'];
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor', 'CPV'];
const rows = filteredTenders.map(t => [
t.ikn,
`"${t.baslik.replace(/"/g, '""')}"`,
@@ -78,7 +80,8 @@ export const TenderList = () => {
t.sehir,
t.tarih,
t.alimTuru,
t.sektor
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');
+13
View File
@@ -60,15 +60,23 @@ export const AppProvider = ({ children }) => {
const toggleSektor = (sek) =>
setSelectedSektorler((prev) => (prev.includes(sek) ? prev.filter((s) => s !== sek) : [...prev, sek]));
// CPV filtresi (bölüm kodu bazlı, çoklu seçim) — sektör deseniyle birebir aynı.
const [selectedCpvler, setSelectedCpvler] = useState([]);
const toggleCpv = (kod) =>
setSelectedCpvler((prev) => (prev.includes(kod) ? prev.filter((k) => k !== kod) : [...prev, kod]));
// Sektör/kurum listeleri sunucudan gelir; sabit liste yalnızca yedektir.
const [sektorListesi, setSektorListesi] = useState(SECTORS_LIST);
const [kurumTipiListesi, setKurumTipiListesi] = useState(INSTITUTION_TYPES_LIST);
// CPV bölüm listesi için statik bir yedek yok — yalnızca sunucudan gelir.
const [cpvBolumListesi, setCpvBolumListesi] = useState([]);
useEffect(() => {
fetch('/api/meta/filtreler')
.then((r) => r.json())
.then((d) => {
if (d?.sektorler?.length) setSektorListesi(d.sektorler);
if (d?.kurumTipleri?.length) setKurumTipiListesi(d.kurumTipleri);
if (d?.cpvBolumler?.length) setCpvBolumListesi(d.cpvBolumler);
})
.catch(() => {});
}, []);
@@ -468,6 +476,7 @@ export const AppProvider = ({ children }) => {
setSelectedAlimTuru('');
setSelectedKurumTipi('');
setSelectedSektorler([]);
setSelectedCpvler([]);
setQuickFilter('all');
};
@@ -505,6 +514,10 @@ export const AppProvider = ({ children }) => {
toggleSektor,
sektorListesi,
kurumTipiListesi,
selectedCpvler,
setSelectedCpvler,
toggleCpv,
cpvBolumListesi,
onlyValidOnly,
setOnlyValidOnly,
quickFilter,