import {validateReminderPreferences,quietNow} from '../services/reminderPreferences'; import {validateTeam} from '../services/teamData'; import {savedFilterCriteria} from '../services/savedFilterCriteria'; import {mergeServerNotifications,acknowledgeNotifications,addLocalNotification} from '../services/notifications'; import { searchTender } from '../services/searchTender'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; import { scanCompanyProposal } from '../services/companyProfile'; 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 { getUserDataSnapshot, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account'; import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tenderSourceChanges } 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(''); const [detailedQueries,setDetailedQueries]=useState({institution:'',registration:''}); // 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 [kalemSonucu, setKalemSonucu] = useState(null); const kalemEslesen = matchingItemResults(kalemSonucu, searchQuery); const [kalemRetry,setKalemRetry] = useState(0); const retryItemSearch = () => setKalemRetry(n=>n+1); const itemSearchState = kalemSonucu?.query === normalizeTenderSearch(searchQuery) ? kalemSonucu : null; useEffect(() => { const q = searchQuery.trim(); if (q.length < 2) { setKalemSonucu(null); return; } let iptal = false; const controller=new AbortController(); setKalemSonucu({query:normalizeTenderSearch(q),status:'loading',matches:null}); const zaman = setTimeout(() => { import('../services/kalem') .then(({ kalemAra }) => kalemAra(q,{signal:controller.signal})) .then((sonuclar) => { if (iptal) return; setKalemSonucu({query:normalizeTenderSearch(q),status:'loaded',matches:sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null}); }) .catch(error => { if (!iptal) setKalemSonucu({query:normalizeTenderSearch(q),status:'error',error:error.message,matches:null}); }); }, 350); return () => { iptal = true; controller.abort(); clearTimeout(zaman); }; }, [searchQuery,kalemRetry]); const [selectedCity, setSelectedCity] = useState(''); const [selectedRegion,setSelectedRegion]=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]); const dataRevisions=useRef({}); // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) useEffect(() => { notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false); aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus(''); dataRevisions.current={};setTeamError(null);setCompetitorError(null);setReminderError(null); 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([getUserDataSnapshot(), getTenderStates()]) .then(([snapshot, records]) => { if (cancelled || getToken() !== loadSession) return; const v=snapshot.veri;dataRevisions.current=snapshot.revisions; 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)) { setSaveError('Bu ihale için önceki kayıt sürüyor. Tamamlandıktan sonra tekrar deneyin.'); 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 }); if(data.historyWarning)setLiveDataError(data.historyWarning); 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); const openSearchTender = (result) => { const existing=tenders.find(t=>t.ikn===result.ikn); const candidate=existing || searchTender(result); const merged=applyTenderStates([candidate],savedTenderStates.current).find(t=>t.ikn===candidate.ikn) || candidate; if(!existing) setTenders(previous=>previous.some(t=>t.ikn===merged.ikn) ? previous : [...previous,merged]); setActiveTenderDetail(merged); }; const [detailLoad, setDetailLoad] = useState(null); const [detailRetry, setDetailRetry] = useState(0); const retryTenderDetail = () => setDetailRetry(value => value + 1); // Canlı ihalede detay açıldığında ilan metnini API'den tamamla useEffect(() => { const t = activeTenderDetail; if (!t || !(t.id?.startsWith('ilan-') || t.id?.startsWith('ekap-')) || t.aciklama) { setDetailLoad(null); return; } let cancelled = false; const controller = new AbortController(); setDetailLoad({id:t.id,status:'loading'}); fetchTenderDetail(t.id, {signal:controller.signal}) .then(detail => { if (cancelled) return; const source = publicTender(detail); source.sourceChanges = tenderSourceChanges(savedTenderStates.current[tenderStateKey(t)]?.snapshot, {...t,...source}); setDetailLoad({id:t.id,status:'loaded'}); setActiveTenderDetail(prev => prev && prev.id === t.id ? { ...prev, ...source } : prev ); setTenders(prev => prev.map(x => (x.id === t.id ? { ...x, ...source } : x)) ); }) .catch(error => { if (!cancelled) setDetailLoad({id:t.id,status:'error',error:error.message}); }); return () => { cancelled = true; controller.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTenderDetail?.id, detailRetry]); 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); const aiRunRef=useRef(null); const [aiJobStatus,setAiJobStatus]=useState(''); // Enterprise Module States // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı) const [competitorWatchlist, setCompetitorWatchlist] = useState([]); const competitorSaveLock=useRef(false); const [competitorSaving,setCompetitorSaving]=useState(false); const [competitorError,setCompetitorError]=useState(null); const saveCompetitorWatchlist=async change=>{ if(!authUser||!veriSenkron){setCompetitorError('Hesap verileri yüklenmeden rakip listesi kaydedilemez.');return false;} if(competitorSaveLock.current)return false; const session=getToken();competitorSaveLock.current=true;setCompetitorSaving(true);setCompetitorError(null); try { const next=typeof change==='function'?change(competitorWatchlist):change; const saved=await setUserData('rakip_izleme',next,dataRevisions.current.rakip_izleme ?? null); if(getToken()!==session)return false; dataRevisions.current.rakip_izleme=saved.revision;setCompetitorWatchlist(next);return true; }catch(error){if(getToken()===session)setCompetitorError(error.message);return false;} finally{competitorSaveLock.current=false;setCompetitorSaving(false);} }; // --- Favori kurumlar (sunucu kaydı) --- const [favoriteKurumlar, setFavoriteKurumlar] = useState([]); const favoriteSaveLock=useRef(false); const [favoriteSaving,setFavoriteSaving]=useState(false); const toggleFavoriteKurum = async kurum => { const ad=typeof kurum==='string' ? kurum.trim() : ''; if(!ad || favoriteSaveLock.current)return false; if(!authUser||!veriSenkron){setSaveError('Hesap verileri yüklenmeden favoriler kaydedilemez.');return false;} const session=getToken();favoriteSaveLock.current=true;setFavoriteSaving(true);setSavingCount(n=>n+1);setSaveError(null); const next=favoriteKurumlar.includes(ad)?favoriteKurumlar.filter(k=>k!==ad):[...favoriteKurumlar,ad]; try { const saved=await setUserData('favori_kurumlar',next,dataRevisions.current.favori_kurumlar ?? null); if(getToken()!==session)return false; dataRevisions.current.favori_kurumlar=saved.revision;setFavoriteKurumlar(next);return true; } catch(error){if(getToken()===session)setSaveError(error.message);return false;} finally{favoriteSaveLock.current=false;setFavoriteSaving(false);setSavingCount(n=>n-1);} }; // --- Ekip yönetimi (sunucu kaydı) --- const [teamMembers, setTeamMembers] = useState([]); const teamSaveLock=useRef(false); const [teamSaving,setTeamSaving]=useState(false); const [teamError,setTeamError]=useState(null); const persistTeam=async next=>{ if(!authUser||!veriSenkron){setTeamError('Hesap verileri yüklenmeden ekip kaydedilemez.');return false;} if(teamSaveLock.current)return false; const session=getToken();teamSaveLock.current=true;setTeamSaving(true);setTeamError(null); try { validateTeam(next); const saved=await setUserData('ekip',next,dataRevisions.current.ekip ?? null); if(getToken()!==session)return false; dataRevisions.current.ekip=saved.revision;setTeamMembers(next);return true; } catch(error){if(getToken()===session)setTeamError(error.message);return false;} finally{teamSaveLock.current=false;setTeamSaving(false);} }; const addTeamMember = member => persistTeam([...teamMembers,{...member,id:'tm-'+crypto.randomUUID()}]); const removeTeamMember = id => persistTeam(teamMembers.filter(member=>member.id!==id)); // Belge kasası — kullanıcı kendi belgelerini ekler (hesaba özel sunucu kaydı) const [vaultDocuments, setVaultDocuments] = useState([]); const [reminderSettings, setReminderSettings] = useState(defaultReminder); const reminderSaveLock=useRef(false),reminderCurrent=useRef(reminderSettings); reminderCurrent.current=reminderSettings; const [reminderSaving,setReminderSaving]=useState(false),[reminderError,setReminderError]=useState(null); const saveReminderSettings=async change=>{ if(!authUser||!veriSenkron){setReminderError('Hesap verileri yüklenmeden bildirim ayarları kaydedilemez.');return false;} if(reminderSaveLock.current)return false; const session=getToken();reminderSaveLock.current=true;setReminderSaving(true);setReminderError(null); try { const next=validateReminderPreferences(typeof change==='function'?change(reminderCurrent.current):change); const saved=await setUserData('reminder',next,dataRevisions.current.reminder ?? null); if(getToken()!==session)return false; dataRevisions.current.reminder=saved.revision;reminderCurrent.current=next;setReminderSettings(next);return true; }catch(error){if(getToken()===session)setReminderError(error.message);return false;} finally{reminderSaveLock.current=false;setReminderSaving(false);} }; const isQuietNow=()=>quietNow(reminderCurrent.current); 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 — kalıcı sunucu işi ü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, extraction, reviewedSource } = {}) => { const session = getToken(); if(aiRunRef.current?.session===session)throw new Error('Bir analiz zaten sürüyor. Kayıtlı işlerden durumunu kontrol edin.'); const run={session};aiRunRef.current=run; setAiAnalyzing(true); setAiAnalysisResults(null); try { const { analyzeSpecText } = await import('../services/ai'); const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource },{onProgress:job=>{if(getToken()===session && aiRunRef.current===run)setAiJobStatus(`${job.id}:${job.state}`);}}); const {analysisView}=await import('../services/ai'); const result=analysisView(data,{tender,mode,fileName}); if(getToken() !== session || aiRunRef.current!==run) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.'); setAiAnalysisResults(result); return result; } finally { if(aiRunRef.current===run){aiRunRef.current=null;setAiAnalyzing(false);} } }; const openSavedAnalysis = async job => { const session=getToken(); if(job?.operation!=='analiz'||job.state!=='completed')throw new Error('Tamamlanmış analiz seçin.'); const {analysisView}=await import('../services/ai'); const result=analysisView(job.result,job.input); if(getToken()!==session)throw new Error('Oturum değişti.'); setAiAnalysisResults(result); return result; }; const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser())); // 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], ); const scanCompanyWebsite = scanCompanyProposal; const profileSaveLock=useRef(false); const updateCompanyCapabilities = async (capabilities,{website=userProfile.website,threshold=userProfile.uyumEsigi}={}) => { if(!authUser||!veriSenkron)throw new Error('Hesap verileri yüklenmeden profil kaydedilemez.'); if(profileSaveLock.current)throw new Error('Önceki profil kaydı sürüyor.'); const session=getToken(); profileSaveLock.current=true; try { const profile={company:userProfile.company,website,scrapedCapabilities:capabilities,uyumEsigi:Math.max(0,Math.min(100,Number(threshold)||0))}; const saved=await setUserData('firma_profil',profile,dataRevisions.current.firma_profil ?? null); if(getToken()!==session)throw new Error('Oturum değişti; profil bu hesaba aktarılmadı.'); dataRevisions.current.firma_profil=saved.revision; setUserProfile(previous=>({...previous,...profile})); return profile; }finally{profileSaveLock.current=false;} }; const [notifications, setNotifications] = useState([]); const [notificationError,setNotificationError]=useState(null); const [notificationBusy,setNotificationBusy]=useState(false); const notificationVersion=useRef(0),notificationMutation=useRef(null),seenServerNotifications=useRef(new Set()); // 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=>addLocalNotification(prev,title,text)); pushNotification(title,text); }; const pushNotification=(title,text)=>{ if (isQuietNow() || !reminderCurrent.current.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 !== 'default') return Notification.permission; return await Notification.requestPermission(); }; const refreshNotifications=async()=>{ const session=getToken(),version=++notificationVersion.current; setNotificationError(null); try { const rows=await bildirimleriGetir(); if(getToken()!==session||version!==notificationVersion.current)return; const checked=mergeServerNotifications([],rows); const fresh=checked.filter(row=>!row.read&&!seenServerNotifications.current.has(row.id)); checked.forEach(row=>seenServerNotifications.current.add(row.id)); fresh.slice(0,5).forEach(row=>pushNotification(row.title,row.text)); setNotifications(current=>mergeServerNotifications(current,rows)); }catch(error){if(getToken()===session&&version===notificationVersion.current)setNotificationError(error.message);} }; const markNotificationsRead=async()=>{ if(notificationMutation.current)return; const operation={session:getToken()};notificationMutation.current=operation; notificationVersion.current++;setNotificationBusy(true);setNotificationError(null); try { const ids=await acknowledgeNotifications(notifications,bildirimOkundu); if(getToken()!==operation.session||notificationMutation.current!==operation)return; notificationVersion.current++; setNotifications(current=>current.map(row=>ids.has(row.id)?{...row,read:true}:row)); }catch(error){if(getToken()===operation.session&¬ificationMutation.current===operation)setNotificationError(error.message);} finally {if(notificationMutation.current===operation){notificationMutation.current=null;setNotificationBusy(false);}} }; // 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]); // (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; void refreshNotifications(); const timer=setInterval(refreshNotifications,5*60*1000); return()=>{notificationVersion.current++;clearInterval(timer);}; }, [veriSenkron,authUser?.email]); // Ş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 filterSaveLock=useRef(false); const persistSavedFilters=async next=>{ if(!authUser||!veriSenkron){setSaveError('Hesap verileri yüklenmeden filtre kaydedilemez.');return false;} if(filterSaveLock.current){setSaveError('Önceki filtre kaydı sürüyor.');return false;} const session=getToken();filterSaveLock.current=true;setSavingCount(n=>n+1);setSaveError(null); try{ const saved=await setUserData('saved_filters',next,dataRevisions.current.saved_filters ?? null); if(getToken()!==session)return false; dataRevisions.current.saved_filters=saved.revision; setSavedFilters(next);return true; }catch(error){if(getToken()===session)setSaveError(error.message);return false;} finally{filterSaveLock.current=false;setSavingCount(n=>n-1);} }; const addSavedFilter = newFilter => persistSavedFilters([{...newFilter,id:'sf-'+crypto.randomUUID(),user:userProfile.username},...savedFilters]); const deleteSavedFilter = id => persistSavedFilters(savedFilters.filter(filter=>filter.id!==id)); const applySavedFilter = filter => { try { const criteria=savedFilterCriteria(filter); setDetailedQueries({institution:'',registration:''}); setSearchQuery('');setSelectedCpvler([]);setQuickFilter('all'); setSelectedCity(criteria.city);setSelectedRegion(criteria.region); setSelectedAlimTuru(criteria.procedure);setSelectedKurumTipi(criteria.institution); setSelectedSektorler(criteria.sector?[criteria.sector]:[]);setActiveTab('list');setSaveError(null); return true; }catch(error){setSaveError(error.message);return false;} }; const clearAllFilters = () => { setDetailedQueries({institution:'',registration:''}); setSelectedRegion(''); setSearchQuery(''); setSelectedCity(''); setSelectedAlimTuru(''); setSelectedKurumTipi(''); setSelectedSektorler([]); setSelectedCpvler([]); setQuickFilter('all'); }; return ( {children} ); }; export const useApp = () => useContext(AppContext);