797 lines
34 KiB
React
797 lines
34 KiB
React
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
|
||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
||
import { getToken } from '../services/auth';
|
||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||
import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
|
||
import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender } from '../services/tenderState';
|
||
import { getTenderStates, saveTenderState } from '../services/account';
|
||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||
|
||
const AppContext = createContext();
|
||
|
||
// İhale önbelleği: tam havuz (10.000+ kayıt) localStorage kotasına sığmaz.
|
||
const TENDER_CACHE_KEY = 'ihaleist_tenders';
|
||
const TENDER_CACHE_LIMIT = 300;
|
||
|
||
// localStorage her zaman yazılabilir değildir (kota dolu, gizli sekme, site
|
||
// verisi kapalı). Yazma hatası uygulamayı çökertmemeli.
|
||
const yerelKaydet = (anahtar, deger) => {
|
||
try {
|
||
localStorage.setItem(anahtar, JSON.stringify(deger));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
const defaultReminder = () => ({emailEnabled:true,smsEnabled:true,pushEnabled:true,
|
||
notify3DaysBefore:true,notify1DayBefore:true,notify3HoursBefore:true,
|
||
googleCalendarSync:true,outlookSync:false,quietEnabled:false,quietStart:'22:00',quietEnd:'08:00'});
|
||
const defaultProfile = user => ({name:user?.name || user?.email || 'Kullanıcı',
|
||
username:(user?.email || '').split('@')[0] || 'kullanici',company:'',website:'',trialDaysLeft:7,
|
||
role:'Yönetici Tedarikçi',uyumEsigi:60,scrapedCapabilities:{scrapedAt:null,extractedSectors:[],
|
||
extractedProducts:[],extractedCertifications:[],ihaleKonulari:[],matchScoreBoost:0}});
|
||
|
||
export const AppProvider = ({ children }) => {
|
||
const [activeTab, setActiveTab] = useState('list'); // 'list', 'bulten', 'sonuc', 'takvim', 'sozlesme', 'kdv', 'fiyat', 'kanban', 'smart_filter_builder'
|
||
const [tenders, setTenders] = useState(() => {
|
||
// Sadece ilk boyama için küçük bir önbellek; asıl kaynak sunucudur.
|
||
// Bozuk/eski kayıt uygulamayı çökertmemeli.
|
||
try {
|
||
const saved = localStorage.getItem(TENDER_CACHE_KEY);
|
||
const parsed = saved ? JSON.parse(saved) : null;
|
||
return Array.isArray(parsed) ? parsed.map(publicTender) : [];
|
||
} catch {
|
||
try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ }
|
||
return [];
|
||
}
|
||
});
|
||
|
||
const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0, arsiv: 0 });
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
|
||
// Kalem havuzunda arama: firmanın kendi ürününü ("eldiven", "ortopedik vida")
|
||
// hangi ihalelerin teklif cetvelinde geçtiğini bulması için. Kartta yalnızca
|
||
// ilk birkaç kalem taşınıyor, tam arama sunucuda yapılıyor.
|
||
const [kalemEslesen, setKalemEslesen] = useState(null); // Map(ikn → eşleşen kalemler)
|
||
useEffect(() => {
|
||
const q = searchQuery.trim();
|
||
if (q.length < 2) { setKalemEslesen(null); return; }
|
||
let iptal = false;
|
||
const zaman = setTimeout(() => {
|
||
import('../services/kalem')
|
||
.then(({ kalemAra }) => kalemAra(q))
|
||
.then((sonuclar) => {
|
||
if (iptal) return;
|
||
setKalemEslesen(sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null);
|
||
})
|
||
.catch(() => { if (!iptal) setKalemEslesen(null); });
|
||
}, 350);
|
||
return () => { iptal = true; clearTimeout(zaman); };
|
||
}, [searchQuery]);
|
||
const [selectedCity, setSelectedCity] = useState('');
|
||
const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
|
||
const [selectedKurumTipi, setSelectedKurumTipi] = 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]));
|
||
|
||
// 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(() => {});
|
||
}, []);
|
||
const [onlyValidOnly, setOnlyValidOnly] = useState(true);
|
||
const [quickFilter, setQuickFilter] = useState('all');
|
||
|
||
const [savedFilters, setSavedFilters] = useState([]);
|
||
|
||
const [isLiveMode, setIsLiveMode] = useState(true);
|
||
const [isRefreshingLive, setIsRefreshingLive] = useState(false);
|
||
const [liveDataError, setLiveDataError] = useState(null);
|
||
const [liveDataSource, setLiveDataSource] = useState(null);
|
||
|
||
// --- Kimlik doğrulama (Google ile Giriş) ---
|
||
const [authUser, setAuthUser] = useState(() => getStoredUser());
|
||
const [authReady, setAuthReady] = useState(false);
|
||
const isAuthenticated = Boolean(authUser);
|
||
const isAdmin = Boolean(authUser?.isAdmin);
|
||
|
||
useEffect(() => {
|
||
restoreSession()
|
||
.then((u) => setAuthUser(u))
|
||
.finally(() => setAuthReady(true));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const sync = event => { if (event.key === 'ihaleist_user' || event.key === 'ihaleist_token') setAuthUser(getStoredUser()); };
|
||
window.addEventListener('storage', sync);
|
||
return () => window.removeEventListener('storage', sync);
|
||
}, []);
|
||
|
||
// "Son girişten beri" kartı için önceki ziyaretin zamanı. Ziyaret = tarayıcı
|
||
// sekmesi oturumu (sessionStorage): sayfa yenilemek sayacı sıfırlamaz, yeni
|
||
// sekme/gün açmak önceki damgayı "önceki giriş" yapar. Eskiden sabit 14'tü.
|
||
const [oncekiGiris, setOncekiGiris] = useState(null);
|
||
useEffect(() => {
|
||
if (!authUser) return;
|
||
try {
|
||
const anahtar = 'ihaleist_son_giris:' + authUser.email;
|
||
const onceki = Number(localStorage.getItem(anahtar)) || null;
|
||
const oturum = sessionStorage.getItem('ihaleist_oturum_giris:' + authUser.email);
|
||
if (oturum !== null) { // aynı sekmede yenileme: damga sabit kalır
|
||
setOncekiGiris(Number(oturum) || null);
|
||
return;
|
||
}
|
||
setOncekiGiris(onceki);
|
||
sessionStorage.setItem('ihaleist_oturum_giris:' + authUser.email, String(onceki || 0));
|
||
localStorage.setItem(anahtar, String(Date.now()));
|
||
} catch { /* depolama kapalıysa kart bugünü gösterir */ }
|
||
}, [authUser?.email]);
|
||
|
||
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
|
||
useEffect(() => {
|
||
setVeriSenkron(false); setPlan(null); setSaveError(null);
|
||
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
|
||
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
|
||
setVaultDocuments([]); setActiveTenderDetail(null); setAiAnalysisResults(null); setNotifications([]); setOncekiGiris(null);
|
||
setTenders(prev => prev.map(publicTender));
|
||
if (!authUser) return;
|
||
planYenile();
|
||
let cancelled = false;
|
||
setVeriSenkron(false);
|
||
ilkYazmaAtlanan.current.clear();
|
||
savedTenderStates.current = {};
|
||
const loadSession = getToken();
|
||
Promise.all([getAllUserData(), getTenderStates()])
|
||
.then(([v, records]) => {
|
||
if (cancelled || getToken() !== loadSession) return;
|
||
setFavoriteKurumlar(v.favori_kurumlar || []);
|
||
setTeamMembers(v.ekip || []);
|
||
setSavedFilters(v.saved_filters || []);
|
||
setReminderSettings({...defaultReminder(), ...v.reminder});
|
||
setCompetitorWatchlist(v.rakip_izleme || []);
|
||
setUserProfile({...defaultProfile(authUser), ...v.firma_profil});
|
||
savedTenderStates.current = records;
|
||
setTenders(prev => applyTenderStates(prev.map(publicTender), records));
|
||
setVeriSenkron(true);
|
||
setSaveError(null);
|
||
})
|
||
.catch((e) => { if (!cancelled && getToken() === loadSession) setSaveError('Hesap verileri yüklenemedi: ' + e.message); });
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [authUser?.email]);
|
||
|
||
// --- Sunucu tarafı kullanıcı verisi ---
|
||
// localStorage yalnızca çevrimdışı önbellek; kaynak doğruluk sunucuda.
|
||
const [plan, setPlan] = useState(null);
|
||
const [veriSenkron, setVeriSenkron] = useState(false);
|
||
const savedTenderStates = useRef({});
|
||
const inFlight = useRef(new Set());
|
||
const [saveError, setSaveError] = useState(null);
|
||
const [savingCount, setSavingCount] = useState(0);
|
||
const mutateTender = async (tenderId, patch) => {
|
||
const t = tenders.find(t => t.id === tenderId);
|
||
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
||
const key = tenderStateKey(t);
|
||
if (inFlight.current.has(key)) return false;
|
||
const owner = authUser.email;
|
||
const session = getToken();
|
||
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
||
try {
|
||
const record = await saveTenderState(key, patch, tenderSnapshot(t), savedTenderStates.current[key]?.version || 0);
|
||
if (getStoredUser()?.email !== owner || getToken() !== session) return false;
|
||
savedTenderStates.current = {...savedTenderStates.current,[key]:record};
|
||
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
|
||
return true;
|
||
} catch (e) {
|
||
if (getStoredUser()?.email !== owner || getToken() !== session) return false;
|
||
setSaveError(e.message);
|
||
if (e.kod === 'VERSION_CONFLICT') {
|
||
try {
|
||
const records = await getTenderStates();
|
||
if (getStoredUser()?.email === owner && getToken() === session) {
|
||
savedTenderStates.current = records;
|
||
setTenders(prev => applyTenderStates(prev.map(publicTender), records));
|
||
}
|
||
} catch { /* retain visible error */ }
|
||
}
|
||
return false;
|
||
} finally { inFlight.current.delete(key); setSavingCount(n => n - 1); }
|
||
};
|
||
|
||
// Sunucu verisi yüklendiğinde her effect bir kez tetiklenir; bu ilk turda
|
||
// yazmak gereksiz (aynı veri) ve boş istemci state'iyle ezme riski taşır.
|
||
const ilkYazmaAtlanan = useRef(new Set());
|
||
// İlk tetiklenme (sunucu verisi yüklenirken) yazılmaz; sonrakiler yazılır.
|
||
const ilkYazmaDegil = (anahtar, deger) => {
|
||
if (ilkYazmaAtlanan.current.has(anahtar)) { sunucuyaYaz(anahtar, deger); return; }
|
||
ilkYazmaAtlanan.current.add(anahtar);
|
||
};
|
||
|
||
|
||
const sunucuyaYaz = (anahtar, deger) => {
|
||
if (!authUser || !veriSenkron || getStoredUser()?.email !== authUser.email) return;
|
||
const session = getToken();
|
||
setUserData(anahtar, deger).catch((e) => {
|
||
if (getToken() !== session) return;
|
||
if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message);
|
||
else setSaveError('Kaydedilemedi: ' + e.message);
|
||
});
|
||
};
|
||
const [kotaUyarisi, setKotaUyarisi] = useState(null);
|
||
|
||
const planYenile = () => { const session=getToken(); return getPlan().then(p => {if(getToken()===session)setPlan(p);}).catch(() => {}); };
|
||
|
||
const handleLogin = (user) => setAuthUser(user);
|
||
const handleLogout = () => {
|
||
clearSession();
|
||
setAuthUser(null);
|
||
savedTenderStates.current = {};
|
||
setTenders([]);
|
||
setActiveTenderDetail(null);
|
||
};
|
||
|
||
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
||
const refreshLiveEKAPData = async ({ refresh = true } = {}) => {
|
||
setIsRefreshingLive(true);
|
||
setLiveDataError(null);
|
||
try {
|
||
const data = await fetchLiveTenders({ refresh });
|
||
setTenders(prev => applyTenderStates(mergeUserState(data.tenders, prev), savedTenderStates.current));
|
||
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
|
||
setLiveStats({
|
||
toplamIhale: data.totalCount ?? 0,
|
||
bugunEklenen: data.todayCount ?? 0,
|
||
arsiv: data.historyCount ?? 0,
|
||
});
|
||
} catch (err) {
|
||
setLiveDataError(err.message);
|
||
} finally {
|
||
setIsRefreshingLive(false);
|
||
}
|
||
};
|
||
|
||
// Açılışta canlı veriyi yükle (başarısız olursa mevcut/mock veri ekranda kalır)
|
||
useEffect(() => {
|
||
refreshLiveEKAPData({ refresh: false });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
||
|
||
// Canlı ihalede detay açıldığında ilan metnini API'den tamamla
|
||
useEffect(() => {
|
||
const t = activeTenderDetail;
|
||
if (!t || !t.id?.startsWith('ilan-') || t.aciklama) return;
|
||
let cancelled = false;
|
||
fetchTenderDetail(t.id)
|
||
.then(detail => {
|
||
if (cancelled || !detail.aciklama) return;
|
||
setActiveTenderDetail(prev =>
|
||
prev && prev.id === t.id ? { ...prev, ...detail } : prev
|
||
);
|
||
setTenders(prev =>
|
||
prev.map(x => (x.id === t.id ? { ...x, ...detail } : x))
|
||
);
|
||
})
|
||
.catch(() => {});
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeTenderDetail?.id]);
|
||
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
|
||
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
|
||
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
||
// Ürün araması: metni ihale dokümanlarının içinde arar (EKAP indeksi)
|
||
const [isUrunAramaOpen, setIsUrunAramaOpen] = useState(false);
|
||
// Sol menüden OKAS koduyla açılınca modal bu kodla başlar
|
||
const [urunAramaBaslangic, setUrunAramaBaslangic] = useState(null);
|
||
|
||
const [aiAnalysisResults, setAiAnalysisResults] = useState(null);
|
||
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
|
||
// tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal.
|
||
const [aiAnalyzing, setAiAnalyzing] = useState(false);
|
||
|
||
// Enterprise Module States
|
||
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı)
|
||
const [competitorWatchlist, setCompetitorWatchlist] = useState([]);
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('rakip_izleme', competitorWatchlist);
|
||
}, [competitorWatchlist, veriSenkron]);
|
||
|
||
// --- Favori kurumlar (sunucu kaydı) ---
|
||
const [favoriteKurumlar, setFavoriteKurumlar] = useState([]);
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('favori_kurumlar', favoriteKurumlar);
|
||
}, [favoriteKurumlar, veriSenkron]);
|
||
const toggleFavoriteKurum = (kurum) => {
|
||
const ad = (kurum || '').trim();
|
||
if (!ad) return;
|
||
setFavoriteKurumlar((prev) => (prev.includes(ad) ? prev.filter((k) => k !== ad) : [...prev, ad]));
|
||
};
|
||
|
||
// --- Ekip yönetimi (sunucu kaydı) ---
|
||
const [teamMembers, setTeamMembers] = useState([]);
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('ekip', teamMembers);
|
||
}, [teamMembers, veriSenkron]);
|
||
const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]);
|
||
const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id));
|
||
|
||
// Belge kasası — kullanıcı kendi belgelerini ekler (hesaba özel sunucu kaydı)
|
||
const [vaultDocuments, setVaultDocuments] = useState([]);
|
||
|
||
const [reminderSettings, setReminderSettings] = useState(defaultReminder);
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('reminder', reminderSettings);
|
||
}, [reminderSettings, veriSenkron]);
|
||
|
||
// Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler)
|
||
const isQuietNow = () => {
|
||
if (!reminderSettings.quietEnabled) return false;
|
||
const now = new Date();
|
||
const cur = now.getHours() * 60 + now.getMinutes();
|
||
const [sh, sm] = reminderSettings.quietStart.split(':').map(Number);
|
||
const [eh, em] = reminderSettings.quietEnd.split(':').map(Number);
|
||
const start = sh * 60 + sm, end = eh * 60 + em;
|
||
return start <= end ? cur >= start && cur < end : cur >= start || cur < end;
|
||
};
|
||
|
||
const [webhooksConfig, setWebhooksConfig] = useState({
|
||
slackWebhookUrl: '',
|
||
teamsWebhookUrl: '',
|
||
events: {
|
||
newTenderMatch: true,
|
||
tenderWon: true,
|
||
bidDeadlineAlert: true,
|
||
competitorActivity: true
|
||
}
|
||
});
|
||
|
||
const [apiKeys, setApiKeys] = useState([]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AI Model Yönetimi (admin panelden seçilebilir + fallback zinciri)
|
||
// ---------------------------------------------------------------------------
|
||
const AI_MODEL_CATALOG = [
|
||
{ id: 'glmflash', label: 'GLM 5.3 Flash', provider: 'Z.ai', or: 'z-ai/glm-5.3-flash', model: 'glm-5.3-flash', renk: 'blue' },
|
||
{ id: 'gemini', label: 'Gemini Flash Latest', provider: 'Google', or: '~google/gemini-flash-latest', model: 'gemini-flash-latest', renk: 'amber' },
|
||
{ id: 'deepseek', label: 'DeepSeek V4 Flash Latest', provider: 'DeepSeek', or: '~deepseek/deepseek-v4-flash-latest', model: 'deepseek-chat', renk: 'indigo' },
|
||
{ id: 'grok', label: 'Grok Latest', provider: 'xAI', or: '~x-ai/grok-latest', model: 'grok-4.6', renk: 'slate' },
|
||
{ id: 'gpt', label: 'GPT Chat Latest', provider: 'OpenAI', or: 'openai/gpt-chat-latest', model: 'gpt-5.6-sol', renk: 'emerald' },
|
||
{ id: 'claude', label: 'Claude Opus Latest', provider: 'Anthropic', or: '~anthropic/claude-opus-latest', model: 'claude-opus-5', renk: 'orange' }
|
||
];
|
||
|
||
// Varsayılan fallback zinciri: GLM 5.3 Flash (birincil) → Gemini → DeepSeek
|
||
// (glm-latest kaldırıldı: birincil ile aynı modele çözünüyordu, gerçek yedek değildi)
|
||
const VARSAYILAN_ZINCIR = ['glmflash', 'gemini', 'deepseek'];
|
||
const ZINCIR_SURUM = 3; // artırıldığında kayıtlı zincir yeni düzene taşınır
|
||
|
||
const [aiModelConfig, setAiModelConfig] = useState(() => {
|
||
const saved = localStorage.getItem('ihaleist_ai_model_config');
|
||
if (saved) {
|
||
try {
|
||
const cfg = JSON.parse(saved);
|
||
// Geçiş: kayıtlı zinciri yeni düzene taşı (glm çıkar, glmflash → gemini başa)
|
||
if ((cfg.surum || 1) < ZINCIR_SURUM) {
|
||
const chain = Array.isArray(cfg.chain) ? cfg.chain : [];
|
||
const kalan = chain.filter((id) => !['glmflash', 'gemini', 'glm'].includes(id));
|
||
return { ...cfg, surum: ZINCIR_SURUM, chain: ['glmflash', 'gemini', ...kalan] };
|
||
}
|
||
// Katalogdan çıkarılmış modelleri kayıtlı zincirden ayıkla
|
||
if (Array.isArray(cfg.chain)) {
|
||
cfg.chain = cfg.chain.filter((id) => AI_MODEL_CATALOG.some((m) => m.id === id));
|
||
if (!cfg.chain.length) cfg.chain = VARSAYILAN_ZINCIR;
|
||
}
|
||
return cfg;
|
||
} catch { /* yoksay */ }
|
||
}
|
||
return { chain: VARSAYILAN_ZINCIR, surum: ZINCIR_SURUM };
|
||
});
|
||
|
||
useEffect(() => {
|
||
yerelKaydet('ihaleist_ai_model_config', aiModelConfig);
|
||
}, [aiModelConfig]);
|
||
|
||
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
|
||
const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0];
|
||
|
||
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
|
||
// seçilen model zinciri (fallback) ile.
|
||
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode } = {}) => {
|
||
const session = getToken();
|
||
setAiAnalyzing(true);
|
||
setAiAnalysisResults(null);
|
||
try {
|
||
const { analyzeSpecText } = await import('../services/ai');
|
||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode });
|
||
const result = {
|
||
...data.result,
|
||
tenderKey: tender?.ikn || tender?.id || null,
|
||
mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'),
|
||
fileName,
|
||
model: `${data.model.label} (${data.model.provider})`,
|
||
modelId: data.model.model,
|
||
fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean),
|
||
attempts: data.attempts,
|
||
analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'),
|
||
};
|
||
if(getToken() !== session) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.');
|
||
setAiAnalysisResults(result);
|
||
return result;
|
||
} finally {
|
||
setAiAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser()));
|
||
useEffect(() => {
|
||
const veri = {
|
||
company: userProfile.company, website: userProfile.website,
|
||
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
|
||
};
|
||
if (veriSenkron) ilkYazmaDegil('firma_profil', veri);
|
||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]);
|
||
const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) }));
|
||
|
||
// Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır,
|
||
// z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz
|
||
// 10 dk'da bir yenilendiği için ihale sayısı değişince tazelenir.
|
||
const [gommeBonus, setGommeBonus] = useState(null);
|
||
const [gommeDurum, setGommeDurum] = useState('kapali'); // kapali | bekliyor | hazir | hata
|
||
const profilMetin = useMemo(() => profilMetni(userProfile), [userProfile.scrapedCapabilities]);
|
||
useEffect(() => {
|
||
if (!profilTerimleri(userProfile).length || !getToken() || !tenders.length) { setGommeBonus(null); return; }
|
||
let iptal = false;
|
||
setGommeDurum('bekliyor');
|
||
const z = setTimeout(() => {
|
||
fetch('/api/uyum/gomme', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||
body: JSON.stringify({ profilMetni: profilMetin }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (iptal) return;
|
||
if (d.success && d.hazir) { setGommeBonus(d.bonus || {}); setGommeDurum('hazir'); }
|
||
else { setGommeBonus(null); setGommeDurum(d.success ? 'kapali' : 'hata'); }
|
||
})
|
||
.catch(() => { if (!iptal) { setGommeBonus(null); setGommeDurum('hata'); } });
|
||
}, 800);
|
||
return () => { iptal = true; clearTimeout(z); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [profilMetin, tenders.length]);
|
||
|
||
// Uyum skoru: sözlüksel (anında, açıklanabilir) + anlamsal bonus (sunucu).
|
||
const tendersSkorlu = useMemo(
|
||
() => havuzuPuanla(tenders, userProfile, gommeBonus),
|
||
[tenders, userProfile.scrapedCapabilities, gommeBonus],
|
||
);
|
||
|
||
// Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
|
||
// alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
|
||
const scanCompanyWebsite = async (url) => {
|
||
const session = getToken();
|
||
let yeni = null;
|
||
try {
|
||
const r = await fetch('/api/company/scan', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
const d = await r.json();
|
||
if (d.success && d.sonuc) {
|
||
const so = d.sonuc;
|
||
yeni = {
|
||
scrapedAt: new Date().toLocaleString('tr-TR'),
|
||
extractedSectors: so.sektorler || [],
|
||
extractedProducts: so.urunler || [],
|
||
extractedCertifications: so.sertifikalar || [],
|
||
ihaleKonulari: so.ihaleKonulari || [],
|
||
ozet: so.ozet || null,
|
||
tarananSayfa: d.tarananSayfa,
|
||
matchScoreBoost: 0,
|
||
gercekTarama: true,
|
||
};
|
||
}
|
||
} catch { /* aşağıda yedek yol */ }
|
||
if (!yeni) yeni = { ...parseWebsiteCapabilitiesDynamically(url), ihaleKonulari: [], gercekTarama: false };
|
||
if(getToken() !== session) throw new Error('Oturum değişti; firma taraması bu hesaba aktarılmadı.');
|
||
setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni }));
|
||
return yeni;
|
||
};
|
||
|
||
const updateCompanyCapabilities = (updatedCapabilities) => {
|
||
setUserProfile(prev => ({
|
||
...prev,
|
||
scrapedCapabilities: {
|
||
...prev.scrapedCapabilities,
|
||
...updatedCapabilities
|
||
}
|
||
}));
|
||
};
|
||
|
||
const [notifications, setNotifications] = useState([]);
|
||
|
||
// Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı
|
||
// bildirimi (Notification API) da gösterir.
|
||
const notify = (title, text) => {
|
||
setNotifications((prev) => [{ id: 'n-' + Date.now(), title, text, time: 'az önce', read: false }, ...prev].slice(0, 30));
|
||
if (isQuietNow() || !reminderSettings.pushEnabled) return;
|
||
try {
|
||
if ('Notification' in window && Notification.permission === 'granted') {
|
||
new Notification(title, { body: text, icon: '/favicon.ico' });
|
||
}
|
||
} catch { /* yoksay */ }
|
||
};
|
||
const requestPushPermission = async () => {
|
||
if (!('Notification' in window)) return 'unsupported';
|
||
if (Notification.permission === 'granted') return 'granted';
|
||
try { return await Notification.requestPermission(); } catch { return 'denied'; }
|
||
};
|
||
const markNotificationsRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||
|
||
// Persist state to localstorage
|
||
// Havuz on binlerce kayda çıkabiliyor; tamamı localStorage kotasını (≈5 MB)
|
||
// aşar ve yakalanmazsa uygulama beyaz ekranla çöker. Bu yüzden yalnızca ilk
|
||
// boyamaya yetecek kadarını, hataya dayanıklı biçimde saklıyoruz.
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(TENDER_CACHE_KEY, JSON.stringify(tenders.slice(0, TENDER_CACHE_LIMIT).map(publicTender)));
|
||
} catch {
|
||
// Kota dolduysa önbelleği bırak — veri zaten sunucudan geliyor.
|
||
try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ }
|
||
}
|
||
}, [tenders]);
|
||
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('saved_filters', savedFilters);
|
||
}, [savedFilters, veriSenkron]);
|
||
|
||
// (Sahte "online kullanıcı" rastgele sayacı kaldırıldı — canlı istatistikler
|
||
// gerçek /api/stats verisinden gelir.)
|
||
|
||
const toggleTrackTender = (tenderId) => {
|
||
const t = tenders.find(t => t.id === tenderId);
|
||
return mutateTender(tenderId, {takipte: !t?.takipte});
|
||
};
|
||
|
||
// Takipteki İKN'ler sunucuya yazılır: zeyilname/düzeltme ilanı ve sonuç
|
||
// bildirimi sunucudaki zamanlayıcıda üretiliyor, tarayıcı kapalıyken de.
|
||
const takipIknler = useMemo(
|
||
() => tenders.filter((t) => t.takipte || t.gorev).map((t) => t.ikn).filter(Boolean).sort(),
|
||
[tenders],
|
||
);
|
||
const takipImza = takipIknler.join('|');
|
||
useEffect(() => {
|
||
if (veriSenkron) ilkYazmaDegil('takip', takipIknler);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [takipImza, veriSenkron]);
|
||
|
||
/**
|
||
* İhaleden hazırlık görevi: teslim tarihi ihale saatidir, panoya "Teklif
|
||
* Hazırlanıyor" kolonunda düşer, takibe alınır — sonuç yayımlanınca bildirim
|
||
* gelir. (Fikir: MOBIT-DURT "ilandan göreve, görevden sonuca".)
|
||
*/
|
||
const gorevAc = async (tenderId, { kontrolListesi = [] } = {}) => {
|
||
const t = tenders.find(t => t.id === tenderId);
|
||
if (!t || t.gorev) return;
|
||
const ok = await mutateTender(tenderId, {
|
||
takipte: true,
|
||
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
|
||
gorev: { acildi: new Date().toISOString(), teslim: t.tarih ? `${t.tarih} ${t.saat || ''}`.trim() : null,
|
||
kontrolListesi: kontrolListesi.map(k => ({ad:typeof k === 'string' ? k : k.document,tamam:false})) },
|
||
});
|
||
if (ok) notify('Hazırlık görevi kaydedildi', 'Görev sunucuda saklandı; Kanban panosundan devam edebilirsiniz.');
|
||
};
|
||
const gorevKontrol = (tenderId, idx) => {
|
||
const t = tenders.find(t => t.id === tenderId);
|
||
if (!t?.gorev) return;
|
||
return mutateTender(tenderId, {gorev:{...t.gorev,kontrolListesi:t.gorev.kontrolListesi.map((k,i) => i === idx ? {...k,tamam:!k.tamam} : k)}});
|
||
};
|
||
|
||
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
|
||
useEffect(() => {
|
||
if (!veriSenkron) return;
|
||
let iptal = false;
|
||
const session = getToken();
|
||
const cek = async () => {
|
||
try {
|
||
const liste = await bildirimleriGetir();
|
||
const yeni = liste.filter((b) => !b.okundu);
|
||
if (iptal || getToken() !== session || !yeni.length) return;
|
||
for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin);
|
||
await bildirimOkundu(yeni.map((b) => b.id));
|
||
} catch { /* sessiz */ }
|
||
};
|
||
cek();
|
||
const z = setInterval(cek, 5 * 60 * 1000);
|
||
return () => { iptal = true; clearInterval(z); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [veriSenkron]);
|
||
|
||
// Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak
|
||
// belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar
|
||
// fark etmiyor — fikir: MOBIT-DURT).
|
||
useEffect(() => {
|
||
if (!veriSenkron) return;
|
||
const session = getToken();
|
||
const uyariAnahtari = 'ihaleist_belge_uyari_gunu:' + authUser?.email;
|
||
const bugun = new Date().toISOString().slice(0, 10);
|
||
try { if (localStorage.getItem(uyariAnahtari) === bugun) return; } catch { /* yoksay */ }
|
||
listDocuments().then((d) => {
|
||
if(getToken() !== session) return;
|
||
const simdi = Date.now();
|
||
const dolan = [], yakin = [];
|
||
for (const b of d?.belgeler || []) {
|
||
if (!b.son_kullanma) continue;
|
||
const kalan = Math.ceil((new Date(b.son_kullanma).getTime() - simdi) / 86400000);
|
||
if (kalan < 0) dolan.push(b.ad); else if (kalan <= 30) yakin.push(`${b.ad} (${kalan} gün)`);
|
||
}
|
||
if (dolan.length) notify(`${dolan.length} belgenin süresi dolmuş`, dolan.slice(0, 4).join(', '));
|
||
if (yakin.length) notify(`${yakin.length} belgenin süresi 30 gün içinde doluyor`, yakin.slice(0, 4).join(', '));
|
||
try { localStorage.setItem(uyariAnahtari, bugun); } catch { /* yoksay */ }
|
||
}).catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [veriSenkron]);
|
||
|
||
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
|
||
const t = tenders.find(t => t.id === tenderId);
|
||
return mutateTender(tenderId, {kanbanStatus:newStatus,atananPersonel:assignedUser ?? t?.atananPersonel ?? null,takipte:true});
|
||
};
|
||
|
||
const addSavedFilter = (newFilter) => {
|
||
const filterObj = {
|
||
id: 'sf-' + Date.now(),
|
||
user: userProfile.username,
|
||
...newFilter
|
||
};
|
||
setSavedFilters(prev => [filterObj, ...prev]);
|
||
};
|
||
|
||
const deleteSavedFilter = (id) => {
|
||
setSavedFilters(prev => prev.filter(f => f.id !== id));
|
||
};
|
||
|
||
const clearAllFilters = () => {
|
||
setSearchQuery('');
|
||
setSelectedCity('');
|
||
setSelectedAlimTuru('');
|
||
setSelectedKurumTipi('');
|
||
setSelectedSektorler([]);
|
||
setSelectedCpvler([]);
|
||
setQuickFilter('all');
|
||
};
|
||
|
||
return (
|
||
<AppContext.Provider value={{
|
||
activeTab,
|
||
setActiveTab,
|
||
tenders: tendersSkorlu,
|
||
uyumEsigi: userProfile.uyumEsigi,
|
||
setUyumEsigi,
|
||
gommeDurum,
|
||
setTenders,
|
||
isLiveMode,
|
||
setIsLiveMode,
|
||
isRefreshingLive,
|
||
refreshLiveEKAPData,
|
||
liveDataError,
|
||
liveDataSource,
|
||
authUser,
|
||
authReady,
|
||
isAuthenticated,
|
||
isAdmin,
|
||
handleLogin,
|
||
handleLogout,
|
||
liveStats,
|
||
searchQuery,
|
||
kalemEslesen,
|
||
setSearchQuery,
|
||
selectedCity,
|
||
setSelectedCity,
|
||
selectedAlimTuru,
|
||
setSelectedAlimTuru,
|
||
selectedKurumTipi,
|
||
setSelectedKurumTipi,
|
||
selectedSektor,
|
||
setSelectedSektor,
|
||
selectedSektorler,
|
||
setSelectedSektorler,
|
||
toggleSektor,
|
||
sektorListesi,
|
||
kurumTipiListesi,
|
||
selectedCpvler,
|
||
setSelectedCpvler,
|
||
toggleCpv,
|
||
cpvBolumListesi,
|
||
onlyValidOnly,
|
||
oncekiGiris,
|
||
setOnlyValidOnly,
|
||
quickFilter,
|
||
setQuickFilter,
|
||
savedFilters,
|
||
addSavedFilter,
|
||
deleteSavedFilter,
|
||
activeTenderDetail,
|
||
setActiveTenderDetail,
|
||
isDetailedSearchOpen,
|
||
setIsDetailedSearchOpen,
|
||
isCompanyScannerOpen,
|
||
setIsCompanyScannerOpen,
|
||
scanCompanyWebsite,
|
||
updateCompanyCapabilities,
|
||
isAiSpecAnalyzerOpen,
|
||
isUrunAramaOpen,
|
||
setIsUrunAramaOpen,
|
||
urunAramaBaslangic,
|
||
setUrunAramaBaslangic,
|
||
setIsAiSpecAnalyzerOpen,
|
||
analyzeSpecPdfWithGemini,
|
||
aiAnalysisResults,
|
||
aiAnalyzing,
|
||
competitorWatchlist,
|
||
setCompetitorWatchlist,
|
||
vaultDocuments,
|
||
setVaultDocuments,
|
||
reminderSettings,
|
||
setReminderSettings,
|
||
webhooksConfig,
|
||
setWebhooksConfig,
|
||
apiKeys,
|
||
setApiKeys,
|
||
AI_MODEL_CATALOG,
|
||
aiModelConfig,
|
||
setAiModelConfig,
|
||
getModelById,
|
||
activeAiModel,
|
||
userProfile,
|
||
notifications,
|
||
notify,
|
||
requestPushPermission,
|
||
markNotificationsRead,
|
||
isQuietNow,
|
||
favoriteKurumlar,
|
||
toggleFavoriteKurum,
|
||
teamMembers,
|
||
addTeamMember,
|
||
removeTeamMember,
|
||
toggleTrackTender,
|
||
updateKanbanStatus,
|
||
gorevAc,
|
||
gorevKontrol,
|
||
clearAllFilters,
|
||
plan,
|
||
planYenile,
|
||
kotaUyarisi,
|
||
setKotaUyarisi,
|
||
veriSenkron, saveError, savingCount, mutateTender
|
||
}}>
|
||
{children}
|
||
</AppContext.Provider>
|
||
);
|
||
};
|
||
|
||
export const useApp = () => useContext(AppContext);
|