Files
ihaleist/src/context/AppContext.jsx
T
hikmettanandClaude Fable 5.1 08c0a0af7e Kullanıcı testi hataları: sabit tarihler, kişisel varsayılanlar, kılavuz düğmesi, mobil başlık, AI taslak çökmesi
- Günlük Bülten ve İhale Takvimi sabit "10.08.2026" yerine bugünden hesaplar;
  takvim 7 günü gerçek ihale tarihleriyle sayar
- Ekip/üye/firma varsayılanları oturumdaki kullanıcıdan; sahte "Ekip Notları"
  ve sabit "%94 Uyum" rozeti kaldırıldı (gerçek uyum skoru varsa gösterilir)
- Kılavuz düğmesi document.body'ye portal (ekran dışına düşüyordu)
- Detay modalı havuzdaki güncel kaydı okur: Takip Et / Görev aç etiketleri güncellenir
- Mobil: başlıkta ikincil düğmeler md altında gizli, arama formu küçülebilir
- PWA sayfası: sahte önbellek sayıları yerine Cache API ve bellek sayımı
- Sözleşme sorgula pazar payı bir ondalık (hep %0 görünüyordu)
- Rakip Radar "SMS" vaadi düzeltildi; sektör=CPV etiketi tekrarı gizlendi;
  Stripe kurulum uyarısı yalnız yöneticiye
- Açılışta 7 gereksiz PUT /api/me/data: ilk yükleme yazımı atlanır
- server: teklif taslağı çökmesi (kalemOku nesne döndürür), denetim yanıtından
  model adı kaldırıldı, gereklilikler satır bazlı; derin analiz 12k token +
  kesik JSON onarımı

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 09:43:41 +03:00

804 lines
32 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 { 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 yerelOku = (anahtar, varsayilan) => {
try {
const ham = localStorage.getItem(anahtar);
if (!ham) return varsayilan;
const v = JSON.parse(ham);
return v == null ? varsayilan : v;
} catch {
return varsayilan;
}
};
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 : [];
} 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 v = yerelOku('ihaleist_saved_filters', []);
return Array.isArray(v) ? v : [];
});
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));
}, []);
// "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';
const onceki = Number(localStorage.getItem(anahtar)) || null;
const oturum = sessionStorage.getItem('ihaleist_oturum_giris');
if (oturum !== null) { // aynı sekmede yenileme: damga sabit kalır
setOncekiGiris(Number(oturum) || null);
return;
}
setOncekiGiris(onceki);
sessionStorage.setItem('ihaleist_oturum_giris', 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(() => {
if (!authUser) { setVeriSenkron(false); setPlan(null); return; }
planYenile();
getAllUserData()
.then((v) => {
if (v.favori_kurumlar) setFavoriteKurumlar(v.favori_kurumlar);
if (v.ekip) setTeamMembers(v.ekip);
if (v.saved_filters) setSavedFilters(v.saved_filters);
if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder }));
if (v.rakip_izleme) setCompetitorWatchlist(v.rakip_izleme);
})
.catch((e) => console.warn('[veri] sunucudan alınamadı:', e.message))
.finally(() => setVeriSenkron(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);
// 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) return;
setUserData(anahtar, deger).catch((e) => {
if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message);
else console.warn('[veri] sunucuya yazılamadı:', e.message);
});
};
const [kotaUyarisi, setKotaUyarisi] = useState(null);
const planYenile = () => getPlan().then(setPlan).catch(() => {});
const handleLogin = (user) => setAuthUser(user);
const handleLogout = () => {
clearSession();
setAuthUser(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 => mergeUserState(data.tenders, prev));
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, aciklama: detail.aciklama } : prev
);
setTenders(prev =>
prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : 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 (localStorage)
const [competitorWatchlist, setCompetitorWatchlist] = useState(() => {
try { return JSON.parse(localStorage.getItem('ihaleist_rakip_izleme') || '[]'); } catch { return []; }
});
useEffect(() => {
yerelKaydet('ihaleist_rakip_izleme', competitorWatchlist);
if (veriSenkron) ilkYazmaDegil('rakip_izleme', competitorWatchlist);
}, [competitorWatchlist, veriSenkron]);
// --- Favori kurumlar (localStorage, gerçek) ---
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
try { return JSON.parse(localStorage.getItem('ihaleist_favori_kurumlar') || '[]'); } catch { return []; }
});
useEffect(() => {
yerelKaydet('ihaleist_favori_kurumlar', favoriteKurumlar);
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 (localStorage, gerçek CRUD) ---
const [teamMembers, setTeamMembers] = useState(() => {
try {
const saved = localStorage.getItem('ihaleist_ekip');
if (saved) return JSON.parse(saved);
} catch { /* yoksay */ }
const u = getStoredUser();
return u ? [{ id: 'tm-1', name: u.name || u.email, email: u.email || '', role: 'Yönetici' }] : [];
});
useEffect(() => {
yerelKaydet('ihaleist_ekip', teamMembers);
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 (localStorage)
const [vaultDocuments, setVaultDocuments] = useState(() => {
try { return JSON.parse(localStorage.getItem('ihaleist_belgeler') || '[]'); } catch { return []; }
});
useEffect(() => {
yerelKaydet('ihaleist_belgeler', vaultDocuments);
}, [vaultDocuments]);
const [reminderSettings, setReminderSettings] = useState(() => {
const def = {
emailEnabled: true, smsEnabled: true, pushEnabled: true,
notify3DaysBefore: true, notify1DayBefore: true, notify3HoursBefore: true,
googleCalendarSync: true, outlookSync: false,
quietEnabled: false, quietStart: '22:00', quietEnd: '08:00',
};
try {
const saved = JSON.parse(localStorage.getItem('ihaleist_reminder') || 'null');
return saved ? { ...def, ...saved } : def;
} catch { return def; }
});
useEffect(() => {
yerelKaydet('ihaleist_reminder', reminderSettings);
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 } = {}) => {
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,
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'),
};
setAiAnalysisResults(result);
return result;
} finally {
setAiAnalyzing(false);
}
};
const BOS_YETKINLIK = {
scrapedAt: null, extractedSectors: [], extractedProducts: [], extractedCertifications: [],
ihaleKonulari: [], matchScoreBoost: 0,
};
const [userProfile, setUserProfile] = useState(() => {
// Yetkinlikler uyum skorunun girdisi — sayfa yenilenince sıfırlanmasın
let kayitli = null;
try { kayitli = JSON.parse(localStorage.getItem('ihaleist_firma_profil') || 'null'); } catch { /* yoksay */ }
const u = getStoredUser();
return {
name: u?.name || u?.email || 'Kullanıcı',
username: (u?.email || '').split('@')[0] || 'kullanici',
company: kayitli?.company || '',
website: kayitli?.website || '',
trialDaysLeft: 7,
role: 'Yönetici Tedarikçi',
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
// Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren
uyumEsigi: Number.isFinite(kayitli?.uyumEsigi) ? kayitli.uyumEsigi : 60,
};
});
useEffect(() => {
const veri = {
company: userProfile.company, website: userProfile.website,
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
};
yerelKaydet('ihaleist_firma_profil', veri);
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) => {
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 };
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)));
} catch {
// Kota dolduysa önbelleği bırak — veri zaten sunucudan geliyor.
try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ }
}
}, [tenders]);
useEffect(() => {
yerelKaydet('ihaleist_saved_filters', savedFilters);
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) => {
setTenders(prev => prev.map(t => {
if (t.id === tenderId) {
return { ...t, takipte: !t.takipte };
}
return t;
}));
};
// 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 = (tenderId, { kontrolListesi = [] } = {}) => {
setTenders((prev) => prev.map((t) => {
if (t.id !== tenderId) return t;
return {
...t,
takipte: true,
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
gorev: {
acildi: new Date().toISOString(),
teslim: t.tarih ? `${t.tarih}${t.saat ? ' ' + t.saat : ''}` : null,
kontrolListesi: kontrolListesi.map((k) => ({ ad: typeof k === 'string' ? k : k.document, tamam: false })),
},
};
}));
notify('Hazırlık görevi açıldı', 'Teslim tarihi ihale saati; pano "Teklif Hazırlanıyor" kolonunda.');
};
const gorevKontrol = (tenderId, idx) => {
setTenders((prev) => prev.map((t) => {
if (t.id !== tenderId || !t.gorev) return t;
const liste = t.gorev.kontrolListesi.map((k, i) => (i === idx ? { ...k, tamam: !k.tamam } : k));
return { ...t, gorev: { ...t.gorev, kontrolListesi: liste } };
}));
};
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
useEffect(() => {
if (!veriSenkron) return;
let iptal = false;
const cek = async () => {
try {
const liste = await bildirimleriGetir();
const yeni = liste.filter((b) => !b.okundu);
if (iptal || !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 bugun = new Date().toISOString().slice(0, 10);
try { if (localStorage.getItem('ihaleist_belge_uyari_gunu') === bugun) return; } catch { /* yoksay */ }
listDocuments().then((d) => {
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('ihaleist_belge_uyari_gunu', bugun); } catch { /* yoksay */ }
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [veriSenkron]);
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
setTenders(prev => prev.map(t => {
if (t.id === tenderId) {
return {
...t,
kanbanStatus: newStatus,
atananPersonel: assignedUser !== null ? assignedUser : t.atananPersonel,
takipte: true
};
}
return t;
}));
};
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
}}>
{children}
</AppContext.Provider>
);
};
export const useApp = () => useContext(AppContext);