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:
@@ -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 ---------- */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user