feat: 30 günlük havuz referansı (kalıcı erime) + 25 sektör, çoklu sektör seçimi ve sunucudan gelen tek kaynaklı liste
This commit is contained in:
@@ -253,11 +253,17 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) {
|
|||||||
|
|
||||||
// 7 günlük referans — tek bir ölçüme değil, ortalamaya kıyaslarız ki
|
// 7 günlük referans — tek bir ölçüme değil, ortalamaya kıyaslarız ki
|
||||||
// havuz yavaş yavaş erirse de yakalayalım.
|
// havuz yavaş yavaş erirse de yakalayalım.
|
||||||
const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString();
|
const ortalamaAl = (gun) => {
|
||||||
const ref = db
|
const bas = new Date(Date.now() - gun * 864e5).toISOString();
|
||||||
.prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet
|
return db
|
||||||
FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`)
|
.prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet
|
||||||
.get(haftaBas) || {};
|
FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`)
|
||||||
|
.get(bas) || {};
|
||||||
|
};
|
||||||
|
// 7 gün: günlük dalgalanma. 30 gün: kalıcı erime — 7 günlük ortalama
|
||||||
|
// düşüşe ayak uydurup alarmı susturduğunda bunu ancak 30 gün yakalar.
|
||||||
|
const ref = ortalamaAl(7);
|
||||||
|
const ref30 = ortalamaAl(30);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO havuz_olcum (zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv)
|
`INSERT INTO havuz_olcum (zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv)
|
||||||
@@ -274,7 +280,7 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) {
|
|||||||
const sinir = new Date(Date.now() - 90 * 864e5).toISOString();
|
const sinir = new Date(Date.now() - 90 * 864e5).toISOString();
|
||||||
db.prepare('DELETE FROM havuz_olcum WHERE zaman < ?').run(sinir);
|
db.prepare('DELETE FROM havuz_olcum WHERE zaman < ?').run(sinir);
|
||||||
|
|
||||||
return degerlendir(onceki, ref, { kaynakToplam, yuklenen });
|
return degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen });
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
@@ -284,19 +290,26 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) {
|
|||||||
const MIN_OLCUM = 5;
|
const MIN_OLCUM = 5;
|
||||||
// Bu oranın altına düşerse uyarılır (7 günlük ortalamanın %60'ı).
|
// Bu oranın altına düşerse uyarılır (7 günlük ortalamanın %60'ı).
|
||||||
const DUSUS_ESIGI = 0.6;
|
const DUSUS_ESIGI = 0.6;
|
||||||
|
// 30 günlük referans için: daha çok ölçüm ister, eşiği daha toleranslıdır —
|
||||||
|
// amaç ani dalgalanma değil, kalıcı erimeyi görmek.
|
||||||
|
const MIN_OLCUM_30 = 20;
|
||||||
|
const DUSUS_ESIGI_30 = 0.7;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ölçümü 7 GÜNLÜK ORTALAMAYA kıyaslar; yeterli geçmiş yoksa bir önceki
|
* Ölçümü 7 GÜNLÜK ORTALAMAYA kıyaslar; yeterli geçmiş yoksa bir önceki
|
||||||
* ölçüme düşer. Uyarı gerekiyorsa açıklamasını döndürür.
|
* ölçüme düşer. Uyarı gerekiyorsa açıklamasını döndürür.
|
||||||
*/
|
*/
|
||||||
function degerlendir(onceki, ref, { kaynakToplam, yuklenen }) {
|
function degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen }) {
|
||||||
const ortalama = ref?.adet >= MIN_OLCUM && ref.ort ? Math.round(ref.ort) : null;
|
const ortalama = ref?.adet >= MIN_OLCUM && ref.ort ? Math.round(ref.ort) : null;
|
||||||
|
const ortalama30 = ref30?.adet >= MIN_OLCUM_30 && ref30.ort ? Math.round(ref30.ort) : null;
|
||||||
const sonuc = {
|
const sonuc = {
|
||||||
uyari: null,
|
uyari: null,
|
||||||
onceki: onceki?.kaynak_toplam ?? null,
|
onceki: onceki?.kaynak_toplam ?? null,
|
||||||
simdi: kaynakToplam ?? null,
|
simdi: kaynakToplam ?? null,
|
||||||
haftalikOrtalama: ortalama,
|
haftalikOrtalama: ortalama,
|
||||||
|
aylikOrtalama: ortalama30,
|
||||||
olcumAdedi: ref?.adet ?? 0,
|
olcumAdedi: ref?.adet ?? 0,
|
||||||
|
olcumAdedi30: ref30?.adet ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1) Kaynak düştü mü? (bizim hatamız değil ama bilmek isteriz)
|
// 1) Kaynak düştü mü? (bizim hatamız değil ama bilmek isteriz)
|
||||||
@@ -321,6 +334,21 @@ function degerlendir(onceki, ref, { kaynakToplam, yuklenen }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b) Kalıcı erime: 7 günlük ortalama düşüşe ayak uydurduğu için sustuysa
|
||||||
|
// bile, 30 günlük referans hâlâ çok yukarıdaysa bunu ayrıca bildiririz.
|
||||||
|
if (kaynakToplam != null && ortalama30 > 0) {
|
||||||
|
const oran30 = kaynakToplam / ortalama30;
|
||||||
|
sonuc.aylikOrtalamayaOran = Number(oran30.toFixed(3));
|
||||||
|
if (oran30 < DUSUS_ESIGI_30 && !sonuc.uyari) {
|
||||||
|
sonuc.uyari = `Kaynak havuzu 30 günlük ortalamanın %${Math.round((1 - oran30) * 100)} altında ` +
|
||||||
|
`(kalıcı düşüş): ortalama ${ortalama30}, şimdi ${kaynakToplam} ilan.`;
|
||||||
|
sonuc.tur = 'kalici_dusus';
|
||||||
|
} else if (oran30 < DUSUS_ESIGI_30 && sonuc.uyari) {
|
||||||
|
// Zaten haftalık uyarı var; 30 günlük bağlamı mesaja ekle.
|
||||||
|
sonuc.uyari += ` (30 günlük ortalama ${ortalama30}, oran %${Math.round(oran30 * 100)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Biz kaynağın gerisinde mi kaldık? (bu BİZİM hatamız olur)
|
// 2) Biz kaynağın gerisinde mi kaldık? (bu BİZİM hatamız olur)
|
||||||
if (!sonuc.uyari && kaynakToplam > 0 && yuklenen != null && yuklenen < kaynakToplam * 0.9) {
|
if (!sonuc.uyari && kaynakToplam > 0 && yuklenen != null && yuklenen < kaynakToplam * 0.9) {
|
||||||
sonuc.uyari = `Havuz eksik yüklendi: kaynakta ${kaynakToplam}, bizde ${yuklenen} ilan.`;
|
sonuc.uyari = `Havuz eksik yüklendi: kaynakta ${kaynakToplam}, bizde ${yuklenen} ilan.`;
|
||||||
@@ -342,3 +370,35 @@ export function havuzGecmisi(limit = 60) {
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Havuz özeti — 7 ve 30 günlük ortalamalar SQL'de hesaplanır; döndürülen
|
||||||
|
* satır sayısından bağımsızdır (60 kayıtlık pencereye sıkışmaz).
|
||||||
|
*/
|
||||||
|
export function havuzOzet() {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
const pencere = (gun) => {
|
||||||
|
const bas = new Date(Date.now() - gun * 864e5).toISOString();
|
||||||
|
const r = db
|
||||||
|
.prepare(`SELECT AVG(kaynak_toplam) ort, MAX(kaynak_toplam) zirve, MIN(kaynak_toplam) dip,
|
||||||
|
COUNT(kaynak_toplam) adet
|
||||||
|
FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`)
|
||||||
|
.get(bas) || {};
|
||||||
|
return {
|
||||||
|
ortalama: r.ort ? Math.round(r.ort) : null,
|
||||||
|
zirve: r.zirve ?? null,
|
||||||
|
dip: r.dip ?? null,
|
||||||
|
adet: r.adet ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const son = db
|
||||||
|
.prepare('SELECT zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv FROM havuz_olcum ORDER BY id DESC LIMIT 1')
|
||||||
|
.get() || null;
|
||||||
|
return { son, hafta: pencere(7), ay: pencere(30) };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+67
-28
@@ -24,7 +24,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio
|
|||||||
import * as store from './user-store.mjs';
|
import * as store from './user-store.mjs';
|
||||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||||
import { searchTed } from './ted.mjs';
|
import { searchTed } from './ted.mjs';
|
||||||
import { readHistory, historyStats, havuzOlc, havuzGecmisi } from './history-store.mjs';
|
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet } from './history-store.mjs';
|
||||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||||
import { searchKik, kararMetni } from './kik.mjs';
|
import { searchKik, kararMetni } from './kik.mjs';
|
||||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||||
@@ -79,18 +79,40 @@ for (const [region, cities] of Object.entries(REGIONS)) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre)
|
// Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sektör sınıflandırması — sıra ÖNEMLİ: ilk eşleşen kazanır, bu yüzden
|
||||||
|
// dar/özel kalıplar geniş olanlardan önce gelir (ör. "tıbbi gaz" → Sağlık,
|
||||||
|
// "doğalgaz" → Elektrik & Enerji).
|
||||||
|
// UYARI: buradaki etiketler src/data/constants.js SECTORS_LIST ile birebir
|
||||||
|
// aynı olmalı. Sürüklenmeyi önlemek için liste /api/meta/sektorler ile sunulur.
|
||||||
const SECTOR_RULES = [
|
const SECTOR_RULES = [
|
||||||
[/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş/i, 'Sağlık / Tıbbi Malzeme'],
|
[/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş |protez|ortez|görüntüleme|röntgen|tomografi|steril|enjektör|eldiven|maske|reaktif|kit alım/i, 'Sağlık / Tıbbi Malzeme'],
|
||||||
[/yazılım|bilişim|sunucu|donanım|kamera|network|bilgisayar/i, 'Bilişim & Yazılım'],
|
[/yazılım|bilişim|sunucu|donanım|network|bilgisayar|lisans|veri merkezi|siber|yazıcı alım|tablet|projeksiyon/i, 'Bilişim & Yazılım'],
|
||||||
[/gıda|yemek|catering|ekmek|et |sebze|meyve|kumanya/i, 'Gıda & Catering'],
|
[/güvenlik hizmet|özel güvenlik|kamera|cctv|alarm|turnike|x-ray|güvenlik sistem/i, 'Güvenlik Sistemleri'],
|
||||||
[/temizlik|hijyen|dezenfek|deterjan/i, 'Temizlik & Hijyen'],
|
[/gıda|yemek|catering|ekmek|et alım|sebze|meyve|kumanya|süt|tavuk|bakliyat|mutfak/i, 'Gıda & Catering'],
|
||||||
[/inşaat|yapım|onarım|tadilat|bakım|asfalt|beton|sulama|yol /i, 'İnşaat & Bakım'],
|
[/temizlik|hijyen|dezenfek|deterjan|çöp|atık toplama|haşere|ilaçlama/i, 'Temizlik & Hijyen'],
|
||||||
[/kırtasiye|matbaa|baskı|kağıt|toner/i, 'Kırtasiye & Büro'],
|
[/asfalt|beton|yol yapım|köprü|altyapı|kanalizasyon|içme suyu|sulama|menfez|istinat/i, 'Altyapı & Yol'],
|
||||||
[/giyim|tekstil|elbise|üniforma|kıyafet/i, 'Tekstil & Giyim'],
|
[/inşaat|yapım işi|onarım|tadilat|güçlendirme|çatı|cephe|restorasyon|bakım onarım/i, 'İnşaat & Bakım'],
|
||||||
[/araç|otomotiv|lastik|akaryakıt|yakıt|kiralama/i, 'Otomotiv & Yedek Parça'],
|
[/kırtasiye|matbaa|baskı|kağıt|toner|kartuş|fotokopi|büro malzeme/i, 'Kırtasiye & Büro'],
|
||||||
[/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş/i, 'Elektrik & Enerji'],
|
[/mobilya|masa|sandalye|dolap|okul sırası|büro mobilya|raf /i, 'Mobilya & Donatı'],
|
||||||
|
[/giyim|tekstil|elbise|üniforma|kıyafet|ayakkabı|çamaşır|kumaş|battaniye/i, 'Tekstil & Giyim'],
|
||||||
|
[/akaryakıt|motorin|benzin|lpg|yakıt alım/i, 'Akaryakıt & Yakıt'],
|
||||||
|
[/araç kirala|taşıt kirala|servis hizmet|personel taşıma|öğrenci taşıma|nakliye|lojistik/i, 'Taşımacılık & Kiralama'],
|
||||||
|
[/araç|otomotiv|lastik|yedek parça|otobüs|kamyon|iş makinesi|greyder|ekskavatör/i, 'Otomotiv & İş Makinesi'],
|
||||||
|
[/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş|solar|kablo|pano |ups /i, 'Elektrik & Enerji'],
|
||||||
|
[/doğalgaz|kalorifer|kazan|ısıtma|soğutma|klima|havalandırma|iklimlendirme|mekanik tesisat/i, 'Isıtma & İklimlendirme'],
|
||||||
|
[/asansör|yürüyen merdiven/i, 'Asansör & Yürüyen Sistem'],
|
||||||
|
[/su arıtma|arıtma tesis|pompa|sondaj|kuyu |su deposu/i, 'Su & Arıtma'],
|
||||||
|
[/tarım|tohum|gübre|fide|zirai|hayvan|yem |veteriner|sera /i, 'Tarım & Hayvancılık'],
|
||||||
|
[/peyzaj|park bahçe|ağaç|çim |fidan|oyun grubu|kent mobilya/i, 'Peyzaj & Park'],
|
||||||
|
[/eğitim hizmet|kurs|seminer|danışmanlık|müşavirlik|etüt proje|mühendislik hizmet|kontrollük/i, 'Danışmanlık & Proje'],
|
||||||
|
[/reklam|tanıtım|organizasyon|fuar|basın|medya|prodüksiyon/i, 'Reklam & Organizasyon'],
|
||||||
|
[/sigorta|zorunlu trafik|kasko/i, 'Sigorta'],
|
||||||
|
[/kitap|yayın|kütüphane|arşiv|dijitalleştirme/i, 'Yayın & Arşiv'],
|
||||||
|
[/spor|halı saha|tribün|fitness|kondisyon/i, 'Spor Tesisleri'],
|
||||||
|
[/laboratuvar cihaz|analiz cihaz|ölçüm cihaz|test cihaz/i, 'Laboratuvar & Ölçüm'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const INSTITUTION_RULES = [
|
const INSTITUTION_RULES = [
|
||||||
[/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'],
|
[/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'],
|
||||||
[/belediye/i, 'Belediyeler'],
|
[/belediye/i, 'Belediyeler'],
|
||||||
@@ -102,6 +124,13 @@ const INSTITUTION_RULES = [
|
|||||||
[/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'],
|
[/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Arayüzün kullandığı sektör listesi buradan türetilir — elle tutulan ikinci
|
||||||
|
// bir liste tutmayız, böylece iki taraf birbirinden ayrı düşemez.
|
||||||
|
const SEKTOR_ETIKETLERI = [...new Set(SECTOR_RULES.map(([, ad]) => ad))].sort((a, b) =>
|
||||||
|
a.localeCompare(b, 'tr'));
|
||||||
|
const KURUM_ETIKETLERI = [...new Set(INSTITUTION_RULES.map(([, ad]) => ad))].sort((a, b) =>
|
||||||
|
a.localeCompare(b, 'tr'));
|
||||||
|
|
||||||
function classify(rules, text, fallback) {
|
function classify(rules, text, fallback) {
|
||||||
for (const [re, label] of rules) if (re.test(text)) return label;
|
for (const [re, label] of rules) if (re.test(text)) return label;
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -965,29 +994,39 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
||||||
|
if (url.pathname === '/api/meta/filtreler') {
|
||||||
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
sektorler: SEKTOR_ETIKETLERI,
|
||||||
|
kurumTipleri: KURUM_ETIKETLERI,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/health/havuz') {
|
if (url.pathname === '/api/health/havuz') {
|
||||||
const gecmis = havuzGecmisi(Number(url.searchParams.get('limit')) || 60);
|
const ozet = havuzOzet();
|
||||||
const son = gecmis[0] || null;
|
if (!ozet) return json(res, 200, { success: true, son: null, olcumSayisi: 0, gecmis: [] });
|
||||||
// Son 7 günün en yükseği — bugünkü sayı buna göre kıyaslanır.
|
const { son, hafta, ay } = ozet;
|
||||||
const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString();
|
const oran = (ort, yeterli) =>
|
||||||
const hafta = gecmis.filter((r) => r.zaman >= haftaBas && r.kaynak_toplam != null);
|
yeterli && son?.kaynak_toplam && ort ? Number((son.kaynak_toplam / ort).toFixed(3)) : null;
|
||||||
const zirve = hafta.reduce((m, r) => Math.max(m, r.kaynak_toplam || 0), 0);
|
const yeterliVeri = hafta.adet >= 5;
|
||||||
// Referans ölçüt ORTALAMA; zirve yalnızca bağlam için gösterilir.
|
const yeterliVeri30 = ay.adet >= 20;
|
||||||
const ortalama = hafta.length ? Math.round(hafta.reduce((t, r) => t + r.kaynak_toplam, 0) / hafta.length) : null;
|
|
||||||
const yeterliVeri = hafta.length >= 5;
|
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
son,
|
son,
|
||||||
haftalikOrtalama: ortalama,
|
// 7 gün: günlük dalgalanma
|
||||||
haftalikZirve: zirve || null,
|
haftalikOrtalama: hafta.ortalama,
|
||||||
olcumAdedi: hafta.length,
|
haftalikZirve: hafta.zirve,
|
||||||
|
olcumAdedi: hafta.adet,
|
||||||
yeterliVeri,
|
yeterliVeri,
|
||||||
ortalamayaOran:
|
ortalamayaOran: oran(hafta.ortalama, yeterliVeri),
|
||||||
yeterliVeri && son?.kaynak_toplam && ortalama
|
// 30 gün: kalıcı erime
|
||||||
? Number((son.kaynak_toplam / ortalama).toFixed(3))
|
aylikOrtalama: ay.ortalama,
|
||||||
: null,
|
aylikZirve: ay.zirve,
|
||||||
olcumSayisi: gecmis.length,
|
aylikDip: ay.dip,
|
||||||
gecmis,
|
olcumAdedi30: ay.adet,
|
||||||
|
yeterliVeri30,
|
||||||
|
aylikOrtalamayaOran: oran(ay.ortalama, yeterliVeri30),
|
||||||
|
gecmis: havuzGecmisi(Number(url.searchParams.get('limit')) || 60),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { SECTORS_LIST, CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
|
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
export const DetailedSearchModal = () => {
|
export const DetailedSearchModal = () => {
|
||||||
@@ -13,8 +13,10 @@ export const DetailedSearchModal = () => {
|
|||||||
setSelectedCity,
|
setSelectedCity,
|
||||||
selectedAlimTuru,
|
selectedAlimTuru,
|
||||||
setSelectedAlimTuru,
|
setSelectedAlimTuru,
|
||||||
selectedSektor,
|
selectedSektorler,
|
||||||
setSelectedSektor,
|
setSelectedSektorler,
|
||||||
|
toggleSektor,
|
||||||
|
sektorListesi,
|
||||||
selectedKurumTipi,
|
selectedKurumTipi,
|
||||||
setSelectedKurumTipi,
|
setSelectedKurumTipi,
|
||||||
onlyValidOnly,
|
onlyValidOnly,
|
||||||
@@ -40,7 +42,7 @@ export const DetailedSearchModal = () => {
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedCity('');
|
setSelectedCity('');
|
||||||
setSelectedAlimTuru('');
|
setSelectedAlimTuru('');
|
||||||
setSelectedSektor('');
|
setSelectedSektorler([]);
|
||||||
setSelectedKurumTipi('');
|
setSelectedKurumTipi('');
|
||||||
setIknQuery('');
|
setIknQuery('');
|
||||||
setKurumQuery('');
|
setKurumQuery('');
|
||||||
@@ -116,16 +118,32 @@ export const DetailedSearchModal = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<label className="block font-bold text-slate-700 mb-1">Sektör / Kategori</label>
|
<label className="block font-bold text-slate-700 mb-1">
|
||||||
<select
|
Sektör / Kategori
|
||||||
value={selectedSektor}
|
<span className="font-normal text-slate-400 ml-1">
|
||||||
onChange={(e) => setSelectedSektor(e.target.value)}
|
(birden fazla seçebilirsiniz{selectedSektorler.length > 0 ? ` — ${selectedSektorler.length} seçili` : ''})
|
||||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
|
</span>
|
||||||
>
|
</label>
|
||||||
<option value="">Tümü</option>
|
<div className="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto bg-slate-50 border border-slate-300 rounded-lg p-2">
|
||||||
{SECTORS_LIST.map(s => <option key={s} value={s}>{s}</option>)}
|
{sektorListesi.map((s) => {
|
||||||
</select>
|
const secili = selectedSektorler.includes(s);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={s}
|
||||||
|
onClick={() => toggleSektor(s)}
|
||||||
|
className={`px-2.5 py-1 rounded-full border text-[11px] font-semibold transition-colors ${
|
||||||
|
secili
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'bg-white border-slate-300 text-slate-600 hover:border-blue-400 hover:text-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
|
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
|
||||||
import {
|
import {
|
||||||
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
|
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
|
||||||
@@ -16,8 +16,10 @@ export const LeftSidebar = () => {
|
|||||||
deleteSavedFilter,
|
deleteSavedFilter,
|
||||||
selectedAlimTuru,
|
selectedAlimTuru,
|
||||||
setSelectedAlimTuru,
|
setSelectedAlimTuru,
|
||||||
selectedSektor,
|
selectedSektorler,
|
||||||
setSelectedSektor,
|
setSelectedSektorler,
|
||||||
|
toggleSektor,
|
||||||
|
sektorListesi,
|
||||||
selectedKurumTipi,
|
selectedKurumTipi,
|
||||||
setSelectedKurumTipi,
|
setSelectedKurumTipi,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
@@ -25,6 +27,7 @@ export const LeftSidebar = () => {
|
|||||||
clearAllFilters
|
clearAllFilters
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
|
const [sektorAra, setSektorAra] = useState('');
|
||||||
const [openSections, setOpenSections] = useState({
|
const [openSections, setOpenSections] = useState({
|
||||||
alimTuru: true,
|
alimTuru: true,
|
||||||
sektorler: true,
|
sektorler: true,
|
||||||
@@ -59,7 +62,7 @@ export const LeftSidebar = () => {
|
|||||||
<span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
|
<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
|
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
|
||||||
</span>
|
</span>
|
||||||
{(quickFilter !== 'all' || selectedSektor || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
|
{(quickFilter !== 'all' || selectedSektorler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
className="text-[11px] font-medium text-blue-600 hover:underline"
|
className="text-[11px] font-medium text-blue-600 hover:underline"
|
||||||
@@ -199,22 +202,42 @@ export const LeftSidebar = () => {
|
|||||||
{openSections.sektorler ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
|
{openSections.sektorler ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||||
</button>
|
</button>
|
||||||
{openSections.sektorler && (
|
{openSections.sektorler && (
|
||||||
<div className="p-3 space-y-1 text-xs max-h-48 overflow-y-auto">
|
<div className="p-3 space-y-1 text-xs">
|
||||||
|
{/* 25 sektör var; aramadan bulmak zor */}
|
||||||
|
<input
|
||||||
|
value={sektorAra}
|
||||||
|
onChange={(e) => setSektorAra(e.target.value)}
|
||||||
|
placeholder="Sektör 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
|
<button
|
||||||
onClick={() => { setSelectedSektor(''); setActiveTab('list'); }}
|
onClick={() => { setSelectedSektorler([]); setActiveTab('list'); }}
|
||||||
className={`w-full text-left px-2 py-1 rounded font-medium ${!selectedSektor ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
|
className={`w-full text-left px-2 py-1 rounded font-medium ${!selectedSektorler.length ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||||
>
|
>
|
||||||
Tüm Sektörler
|
Tüm Sektörler {selectedSektorler.length > 0 && <span className="text-slate-400">({selectedSektorler.length} seçili)</span>}
|
||||||
</button>
|
</button>
|
||||||
{SECTORS_LIST.map(sec => (
|
{/* Çoklu seçim: kutucuklar birikir, liste kapanmaz */}
|
||||||
<button
|
{sektorListesi
|
||||||
key={sec}
|
.filter((sec) => sec.toLocaleLowerCase('tr').includes(sektorAra.toLocaleLowerCase('tr')))
|
||||||
onClick={() => { setSelectedSektor(sec); setActiveTab('list'); }}
|
.map(sec => {
|
||||||
className={`w-full text-left px-2 py-1 rounded truncate font-medium ${selectedSektor === sec ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
|
const secili = selectedSektorler.includes(sec);
|
||||||
>
|
return (
|
||||||
{sec}
|
<label
|
||||||
</button>
|
key={sec}
|
||||||
))}
|
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={() => { toggleSektor(sec); setActiveTab('list'); }}
|
||||||
|
className="accent-blue-600 shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{sec}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { INSTITUTION_TYPES_LIST, CITIES_LIST, SECTORS_LIST } from '../data/constants';
|
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
|
||||||
import { Bot, ArrowLeft, CheckCircle2, HelpCircle, Building2, MapPin, Tag } from 'lucide-react';
|
import { Bot, ArrowLeft, CheckCircle2, HelpCircle, Building2, MapPin, Tag } from 'lucide-react';
|
||||||
|
|
||||||
export const SmartFilterBuilder = () => {
|
export const SmartFilterBuilder = () => {
|
||||||
const { savedFilters, addSavedFilter, deleteSavedFilter, setActiveTab } = useApp();
|
const { savedFilters, addSavedFilter, deleteSavedFilter, setActiveTab, sektorListesi } = useApp();
|
||||||
|
|
||||||
const [filterName, setFilterName] = useState('');
|
const [filterName, setFilterName] = useState('');
|
||||||
const [selectedKurumTip, setSelectedKurumTip] = useState('');
|
const [selectedKurumTip, setSelectedKurumTip] = useState('');
|
||||||
@@ -238,7 +238,7 @@ export const SmartFilterBuilder = () => {
|
|||||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-xs text-slate-800"
|
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-xs text-slate-800"
|
||||||
>
|
>
|
||||||
<option value="">Tüm Sektörler</option>
|
<option value="">Tüm Sektörler</option>
|
||||||
{SECTORS_LIST.map(s => (
|
{sektorListesi.map(s => (
|
||||||
<option key={s} value={s}>{s}</option>
|
<option key={s} value={s}>{s}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const TenderList = () => {
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
selectedAlimTuru,
|
selectedAlimTuru,
|
||||||
selectedSektor,
|
selectedSektorler,
|
||||||
selectedKurumTipi,
|
selectedKurumTipi,
|
||||||
quickFilter,
|
quickFilter,
|
||||||
onlyValidOnly,
|
onlyValidOnly,
|
||||||
@@ -52,7 +52,7 @@ export const TenderList = () => {
|
|||||||
// Dropdown Filters
|
// Dropdown Filters
|
||||||
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
||||||
if (selectedAlimTuru && tender.alimTuru !== selectedAlimTuru) return false;
|
if (selectedAlimTuru && tender.alimTuru !== selectedAlimTuru) return false;
|
||||||
if (selectedSektor && tender.sektor !== selectedSektor) return false;
|
if (selectedSektorler.length && !selectedSektorler.includes(tender.sektor)) return false;
|
||||||
if (selectedKurumTipi && tender.kurumTipi !== selectedKurumTipi) return false;
|
if (selectedKurumTipi && tender.kurumTipi !== selectedKurumTipi) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -62,7 +62,7 @@ export const TenderList = () => {
|
|||||||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektor, selectedKurumTipi, quickFilter, sortBy]);
|
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedKurumTipi, quickFilter, sortBy]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredTenders.length / itemsPerPage));
|
const totalPages = Math.max(1, Math.ceil(filteredTenders.length / itemsPerPage));
|
||||||
const paginatedTenders = filteredTenders.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
const paginatedTenders = filteredTenders.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { getToken } from '../../services/auth';
|
import { getToken } from '../../services/auth';
|
||||||
import { SECTORS_LIST } from '../../data/constants';
|
|
||||||
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,7 +10,7 @@ import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles,
|
|||||||
* isteğe bağlıdır (varsayılan: tüm Türkiye).
|
* isteğe bağlıdır (varsayılan: tüm Türkiye).
|
||||||
*/
|
*/
|
||||||
export const NotifySubscriptions = () => {
|
export const NotifySubscriptions = () => {
|
||||||
const { authUser, favoriteKurumlar, userProfile } = useApp();
|
const { authUser, favoriteKurumlar, userProfile, sektorListesi } = useApp();
|
||||||
|
|
||||||
const [abonelikler, setAbonelikler] = useState(null);
|
const [abonelikler, setAbonelikler] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -212,7 +212,7 @@ export const NotifySubscriptions = () => {
|
|||||||
|
|
||||||
<p className="text-[10px] text-slate-400 pt-1">Hazır sektörler:</p>
|
<p className="text-[10px] text-slate-400 pt-1">Hazır sektörler:</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{SECTORS_LIST.map((s) => (
|
{sektorListesi.map((s) => (
|
||||||
<button key={s} onClick={() => konuEkle(s)}
|
<button key={s} onClick={() => konuEkle(s)}
|
||||||
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md">
|
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md">
|
||||||
+ {s}
|
+ {s}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperServi
|
|||||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||||
import { getAllUserData, setUserData, getPlan } from '../services/account';
|
import { getAllUserData, setUserData, getPlan } from '../services/account';
|
||||||
|
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -51,7 +52,26 @@ export const AppProvider = ({ children }) => {
|
|||||||
const [selectedCity, setSelectedCity] = useState('');
|
const [selectedCity, setSelectedCity] = useState('');
|
||||||
const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
|
const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
|
||||||
const [selectedKurumTipi, setSelectedKurumTipi] = useState('');
|
const [selectedKurumTipi, setSelectedKurumTipi] = useState('');
|
||||||
const [selectedSektor, setSelectedSektor] = useState('');
|
// Çoklu sektör seçimi. Tek seçimlik eski API (selectedSektor) uyumluluk
|
||||||
|
// için korunur: dizinin ilk elemanını okur, yazınca diziyi tek elemana indirir.
|
||||||
|
const [selectedSektorler, setSelectedSektorler] = useState([]);
|
||||||
|
const selectedSektor = selectedSektorler[0] || '';
|
||||||
|
const setSelectedSektor = (v) => setSelectedSektorler(v ? [v] : []);
|
||||||
|
const toggleSektor = (sek) =>
|
||||||
|
setSelectedSektorler((prev) => (prev.includes(sek) ? prev.filter((s) => s !== sek) : [...prev, sek]));
|
||||||
|
|
||||||
|
// Sektör/kurum listeleri sunucudan gelir; sabit liste yalnızca yedektir.
|
||||||
|
const [sektorListesi, setSektorListesi] = useState(SECTORS_LIST);
|
||||||
|
const [kurumTipiListesi, setKurumTipiListesi] = useState(INSTITUTION_TYPES_LIST);
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
const [onlyValidOnly, setOnlyValidOnly] = useState(true);
|
const [onlyValidOnly, setOnlyValidOnly] = useState(true);
|
||||||
const [quickFilter, setQuickFilter] = useState('all');
|
const [quickFilter, setQuickFilter] = useState('all');
|
||||||
|
|
||||||
@@ -447,7 +467,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
setSelectedCity('');
|
setSelectedCity('');
|
||||||
setSelectedAlimTuru('');
|
setSelectedAlimTuru('');
|
||||||
setSelectedKurumTipi('');
|
setSelectedKurumTipi('');
|
||||||
setSelectedSektor('');
|
setSelectedSektorler([]);
|
||||||
setQuickFilter('all');
|
setQuickFilter('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,6 +500,11 @@ export const AppProvider = ({ children }) => {
|
|||||||
setSelectedKurumTipi,
|
setSelectedKurumTipi,
|
||||||
selectedSektor,
|
selectedSektor,
|
||||||
setSelectedSektor,
|
setSelectedSektor,
|
||||||
|
selectedSektorler,
|
||||||
|
setSelectedSektorler,
|
||||||
|
toggleSektor,
|
||||||
|
sektorListesi,
|
||||||
|
kurumTipiListesi,
|
||||||
onlyValidOnly,
|
onlyValidOnly,
|
||||||
setOnlyValidOnly,
|
setOnlyValidOnly,
|
||||||
quickFilter,
|
quickFilter,
|
||||||
|
|||||||
+22
-4
@@ -4,15 +4,33 @@
|
|||||||
* kategori listeleridir; il-plaka.mjs gibi kanonik sabitlerdir.
|
* kategori listeleridir; il-plaka.mjs gibi kanonik sabitlerdir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Yedek liste — canlıda /api/meta/filtreler'den gelen liste kullanılır.
|
||||||
export const SECTORS_LIST = [
|
export const SECTORS_LIST = [
|
||||||
"Sağlık / Tıbbi Malzeme",
|
"Akaryakıt & Yakıt",
|
||||||
"Kırtasiye & Büro",
|
"Altyapı & Yol",
|
||||||
|
"Asansör & Yürüyen Sistem",
|
||||||
"Bilişim & Yazılım",
|
"Bilişim & Yazılım",
|
||||||
|
"Danışmanlık & Proje",
|
||||||
|
"Elektrik & Enerji",
|
||||||
|
"Güvenlik Sistemleri",
|
||||||
"Gıda & Catering",
|
"Gıda & Catering",
|
||||||
|
"Isıtma & İklimlendirme",
|
||||||
"İnşaat & Bakım",
|
"İnşaat & Bakım",
|
||||||
"Temizlik & Hijyen",
|
"Kırtasiye & Büro",
|
||||||
|
"Laboratuvar & Ölçüm",
|
||||||
|
"Mobilya & Donatı",
|
||||||
|
"Otomotiv & İş Makinesi",
|
||||||
|
"Peyzaj & Park",
|
||||||
|
"Reklam & Organizasyon",
|
||||||
|
"Sağlık / Tıbbi Malzeme",
|
||||||
|
"Sigorta",
|
||||||
|
"Spor Tesisleri",
|
||||||
|
"Su & Arıtma",
|
||||||
|
"Tarım & Hayvancılık",
|
||||||
|
"Taşımacılık & Kiralama",
|
||||||
"Tekstil & Giyim",
|
"Tekstil & Giyim",
|
||||||
"Otomotiv & Yedek Parça",
|
"Temizlik & Hijyen",
|
||||||
|
"Yayın & Arşiv",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INSTITUTION_TYPES_LIST = [
|
export const INSTITUTION_TYPES_LIST = [
|
||||||
|
|||||||
Reference in New Issue
Block a user