Files
ihaleist/src/context/AppContext.jsx
T

797 lines
34 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);