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 ( {children} ); }; export const useApp = () => useContext(AppContext);