fix: persist competitor tracking and remove unsupported metrics and alerts

This commit is contained in:
2026-09-06 14:12:43 +03:00
parent 463fd06d30
commit 83df706631
7 changed files with 74 additions and 51 deletions
+7
View File
@@ -1016,3 +1016,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- favori_kurumlar API yazımında okunan içerik sürümü zorunlu; eski sürüm 409 alır. Liste olmayan, boş veya yinelenen kurum girdileri reddedilir. Boş listeye dönmek desteklenir. - favori_kurumlar API yazımında okunan içerik sürümü zorunlu; eski sürüm 409 alır. Liste olmayan, boş veya yinelenen kurum girdileri reddedilir. Boş listeye dönmek desteklenir.
- 229 otomatik test ve derleme geçti. HTTP testi sürüm zorunluluğu, art arda başarılı kayıt, eski sekme silme isteğinin reddi, hatalı liste ve son favoriyi kaldırmayı doğruladı. - 229 otomatik test ve derleme geçti. HTTP testi sürüm zorunluluğu, art arda başarılı kayıt, eski sekme silme isteğinin reddi, hatalı liste ve son favoriyi kaldırmayı doğruladı.
- Yeni yıldızın tarayıcı bekleme/hata ve iki sekme kabulü açık. Bu değişiklik bildirim aboneliğini otomatik değiştirmez; abonelik kendi kaydedilmiş kurum kapsamını kullanır. Diğer genel anahtarlardan rakip izleme ve hatırlatma ayarlarının eşzamanlı kayıt davranışı ayrıca incelenmeli. Canlıya yayın yapılmadı. - Yeni yıldızın tarayıcı bekleme/hata ve iki sekme kabulü açık. Bu değişiklik bildirim aboneliğini otomatik değiştirmez; abonelik kendi kaydedilmiş kurum kapsamını kullanır. Diğer genel anahtarlardan rakip izleme ve hatırlatma ayarlarının eşzamanlı kayıt davranışı ayrıca incelenmeli. Canlıya yayın yapılmadı.
### Rakip listesinde kalıcılık ve kaynaksız iddiaların kaldırılması — 6 Eylül 2026
- Rakip ekleme/çıkarma artık sürümlü sunucu onayını bekler; hata halinde mevcut liste ve ekleme girdisi korunur. Otomatik effect kaydı ve kayıt gerçekleşmeden başarı konfeti kaldırıldı. API geçersiz ve aynı ad/kimlikle yinelenen firmaları reddeder.
- Elle eklenen firmaya sabit %5 pazar payı ve varsayılan sıfır sonuç gösterimi kaldırıldı. Kayıtlı satırlar güncel yüklenen sonuç havuzundaki firma analiziyle eşleşirse bu havuzun verisini gösterir; eşleşme yoksa kazanım, hacim ve pay Veri yok olur. Eski kayıt içindeki sabit/stale ölçüler gösterilmez.
- Sunucuda rakip özel bildirim tüketicisi bulunmadığı doğrulandı. Otomatik bildirimler aktif metni, proaktif uyarı vaadi ve navigasyondaki Canlı Uyar rozeti kaldırıldı. Bildirim kontrolleri etkin değil olarak gösterilir; dış mesaj gönderilmez. Yasaklı sorgu bağlantısı da doğrulanmamış pano-kopyalandı iddiası taşımaz.
- 230 otomatik test ve derleme geçti. Rakip API sürüm zorunluluğu, eski sürüm, bozuk/yinelenen liste, kayıt korunması ve kaldırma test edildi. İzole tarayıcıda 7 kontrol geçti: kayıt, üç ölçüde Veri yok, bildirimlerin pasif olması, yenileme kalıcılığı, yanıltıcı rozetin kalkması, yinelenen ekleme engeli, çıkarma. Kanıt qa-radar-persistence.json.
- Gerçek kazanan kaynaklarıyla eşleşmenin ekonomik doğruluğu, rakip özel bildirim özelliğinin uygulanması ve iki sekmeli rakip UI çakışması bu kabulde tamamlanmadı. Bu değişiklik çalışmayan bildirim özelliğini çalışıyor göstermez. QA sekmesi ve yeni süreç kapatıldı; canlıya yayın yapılmadı.
+2
View File
@@ -79,3 +79,5 @@ Kuyruktaki ilanın artık kaynakta bulunmaması, teklif zamanının değişmesi,
Ekip kaydı (`ekip`) da aynı içerik sürümü sözleşmesine dahil edildi. Yeni ekip arayüzü sunucu onayını bekler. Eski istemciden sürümsüz ekip yazımı 428 ile reddedilir; frontend/backend birlikte güncellenmelidir. Ekip alanlarının geçersiz veya kimliklerinin yinelenmiş olduğu eski kayıtlar önce düzeltilmelidir; sessiz veri temizliği yapılmaz. Ekip kaydı (`ekip`) da aynı içerik sürümü sözleşmesine dahil edildi. Yeni ekip arayüzü sunucu onayını bekler. Eski istemciden sürümsüz ekip yazımı 428 ile reddedilir; frontend/backend birlikte güncellenmelidir. Ekip alanlarının geçersiz veya kimliklerinin yinelenmiş olduğu eski kayıtlar önce düzeltilmelidir; sessiz veri temizliği yapılmaz.
Favori kurumlar (`favori_kurumlar`) da sürümlü kullanıcı verisi yazımı ister. Eski istemcinin sürümsüz yazımı 428 döner; frontend/backend birlikte sürümlenmelidir. Bekleyen değişiklikler sunucudan onay gelmeden favori listesinde gösterilmez. Favori kurumlar (`favori_kurumlar`) da sürümlü kullanıcı verisi yazımı ister. Eski istemcinin sürümsüz yazımı 428 döner; frontend/backend birlikte sürümlenmelidir. Bekleyen değişiklikler sunucudan onay gelmeden favori listesinde gösterilmez.
Rakip izleme (`rakip_izleme`) da içerik sürümü zorunlu anahtarlara dahil edildi. Eski istemcilerde sürümsüz yazım 428 alır; güncel frontend ile aynı sürüm backend kullanılmalıdır. Eski rakip kayıtlarındaki ölçüler yeni arayüzde güncel havuz eşleşmesi yerine kullanılmaz; kayıtlı firma adı/sektör korunur.
+3 -1
View File
@@ -1209,7 +1209,9 @@ const server = http.createServer(async (req, res) => {
} }
if(k==='favori_kurumlar' && (!Array.isArray(body.deger) || body.deger.some(name=>typeof name!=='string'||!name.trim()||name.length>1000) || new Set(body.deger.map(name=>name.trim())).size!==body.deger.length)) if(k==='favori_kurumlar' && (!Array.isArray(body.deger) || body.deger.some(name=>typeof name!=='string'||!name.trim()||name.length>1000) || new Set(body.deger.map(name=>name.trim())).size!==body.deger.length))
return json(res,400,{success:false,error:'Favori kurum listesi geçersiz veya yinelenen kurum içeriyor.'}); return json(res,400,{success:false,error:'Favori kurum listesi geçersiz veya yinelenen kurum içeriyor.'});
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar'].includes(k); if(k==='rakip_izleme' && (!Array.isArray(body.deger) || body.deger.some(item=>!item || typeof item.id!=='string'||!item.id||typeof item.name!=='string'||!item.name.trim()||item.name.length>1000||(item.sector!=null&&typeof item.sector!=='string')) || new Set(body.deger.map(item=>item.id)).size!==body.deger.length || new Set(body.deger.map(item=>item.name.trim().toLocaleLowerCase('tr-TR'))).size!==body.deger.length))
return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'});
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme'].includes(k);
if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision))))) if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision)))))
return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'}); return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'});
const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined); const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined);
+1 -1
View File
@@ -60,7 +60,7 @@ export const TopNavigation = () => {
{ id: 'sinir', label: 'Sınır Değer', icon: Calculator }, { id: 'sinir', label: 'Sınır Değer', icon: Calculator },
{ id: 'fiyat', label: 'Fiyat Analizi', icon: TrendingUp }, { id: 'fiyat', label: 'Fiyat Analizi', icon: TrendingUp },
{ id: 'kanban', label: 'Kanban Pano', icon: Kanban, badge: 'Sürükle Takip' }, { id: 'kanban', label: 'Kanban Pano', icon: Kanban, badge: 'Sürükle Takip' },
{ id: 'rakip_radar', label: 'Rakip Radar', icon: Radar, badge: 'Canlı Uyar' }, { id: 'rakip_radar', label: 'Rakip Radar', icon: Radar },
{ id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban }, { id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban },
{ id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 }, { id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 },
{ id: 'kirmizi_bayrak', label: 'Kırmızı Bayrak', icon: ShieldAlert, badge: 'Şeffaflık' }, { id: 'kirmizi_bayrak', label: 'Kırmızı Bayrak', icon: ShieldAlert, badge: 'Şeffaflık' },
+34 -44
View File
@@ -1,11 +1,10 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { Radar, Bell, Plus, ShieldAlert, Award, FileText, CheckCircle2, Trash2, ArrowRight, TrendingUp } from 'lucide-react'; import { Radar, Bell, Plus, ShieldAlert, Award, FileText, CheckCircle2, Trash2, ArrowRight, TrendingUp } from 'lucide-react';
import confetti from 'canvas-confetti';
import { firmStats, formatTL } from '../../services/analytics'; import { firmStats, formatTL } from '../../services/analytics';
export const CompetitorRadar = () => { export const CompetitorRadar = () => {
const { competitorWatchlist, setCompetitorWatchlist, setActiveTab, tenders } = useApp(); const { competitorWatchlist, saveCompetitorWatchlist, competitorSaving, competitorError, setActiveTab, tenders } = useApp();
const [newCompetitorName, setNewCompetitorName] = useState(''); const [newCompetitorName, setNewCompetitorName] = useState('');
const [newSector, setNewSector] = useState('Medikal & Tıbbi Malzeme'); const [newSector, setNewSector] = useState('Medikal & Tıbbi Malzeme');
@@ -14,40 +13,30 @@ export const CompetitorRadar = () => {
const watched = new Set(competitorWatchlist.map((c) => c.name.trim().toLocaleLowerCase('tr-TR'))); const watched = new Set(competitorWatchlist.map((c) => c.name.trim().toLocaleLowerCase('tr-TR')));
const addFromReal = (f) => { const addFromReal = (f) => {
if (watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))) return; if (watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))) return;
setCompetitorWatchlist((prev) => [ saveCompetitorWatchlist((prev) => [
{ id: 'cw-' + Date.now(), name: f.name, sector: f.sectors[0] || 'Genel', winCount: f.wins, totalRevenue: f.total, marketShare: f.share, status: 'Aktif İzleniyor', alertOnBid: true, alertOnWin: true }, { id: 'cw-' + crypto.randomUUID(), name: f.name, sector: f.sectors[0] || 'Genel', winCount: f.wins, totalRevenue: f.total, marketShare: f.share, status: 'Aktif İzleniyor', alertOnBid: true, alertOnWin: true },
...prev, ...prev,
]); ]);
confetti({ particleCount: 40 });
}; };
const handleAddCompetitor = (e) => { const handleAddCompetitor = async (e) => {
e.preventDefault(); e.preventDefault();
if (!newCompetitorName.trim()) return; if (!newCompetitorName.trim() || watched.has(newCompetitorName.trim().toLocaleLowerCase('tr-TR'))) return;
const newComp = { const newComp = {
id: 'cw-' + Date.now(), id: 'cw-' + crypto.randomUUID(),
name: newCompetitorName, name: newCompetitorName.trim(),
sector: newSector, sector: newSector,
winCount: 0,
totalRevenue: 0,
marketShare: 5,
status: 'Aktif İzleniyor', status: 'Aktif İzleniyor',
alertOnBid: true, alertOnBid: true,
alertOnWin: true alertOnWin: true
}; };
setCompetitorWatchlist(prev => [newComp, ...prev]); if(await saveCompetitorWatchlist(prev => [newComp, ...prev]))setNewCompetitorName('');
setNewCompetitorName('');
confetti({ particleCount: 50 });
};
const toggleAlert = (id, field) => {
setCompetitorWatchlist(prev => prev.map(c => c.id === id ? { ...c, [field]: !c[field] } : c));
}; };
const removeCompetitor = (id) => { const removeCompetitor = (id) => {
setCompetitorWatchlist(prev => prev.filter(c => c.id !== id)); saveCompetitorWatchlist(prev => prev.filter(c => c.id !== id));
}; };
return ( return (
@@ -61,12 +50,12 @@ export const CompetitorRadar = () => {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-extrabold text-xl">Rakip Radar & Proaktif İhale Uyarısı</h2> <h2 className="font-extrabold text-xl">Rakip Radar</h2>
<span className="bg-indigo-500 text-white text-[10px] font-extrabold px-2.5 py-0.5 rounded-full uppercase tracking-wider"> <span className="bg-indigo-500 text-white text-[10px] font-extrabold px-2.5 py-0.5 rounded-full uppercase tracking-wider">
Proaktif Takip Kayıtlı Firmalar
</span> </span>
</div> </div>
<p className="text-xs text-indigo-200 mt-0.5">İzlemeye aldığınız rakip firmalar ihaleye teklif verdiğinde veya kazandığında uygulama içi, e-posta ve Telegram bildirimi alın.</p> <p className="text-xs text-indigo-200 mt-0.5">Rakip listenizi kaydedin ve yüklenen sonuç/sözleşme verileriyle karşılaştırın. Rakip teklif veya kazanımına özel otomatik bildirim şu anda etkin değil.</p>
</div> </div>
</div> </div>
@@ -119,8 +108,7 @@ export const CompetitorRadar = () => {
</span> </span>
)} )}
<a href="https://ekapv2.kik.gov.tr/sorgulamalar/yasak-sorgulama" target="_blank" rel="noopener noreferrer" <a href="https://ekapv2.kik.gov.tr/sorgulamalar/yasak-sorgulama" target="_blank" rel="noopener noreferrer"
onClick={() => { try { navigator.clipboard?.writeText(f.name); } catch { /* yoksay */ } }} title="KİK yasaklılar sorgusunu açar; firma adını sorguya girin"
title="KİK yasaklılar sorgusunu açar; firma adı panoya kopyalanır (EKAP sorguyu API'den vermiyor)"
className="bg-slate-100 text-slate-700 border border-slate-200 px-1.5 py-0.5 rounded font-bold hover:bg-slate-200"> className="bg-slate-100 text-slate-700 border border-slate-200 px-1.5 py-0.5 rounded font-bold hover:bg-slate-200">
⛔ yasaklı sorgula ⛔ yasaklı sorgula
</a> </a>
@@ -134,7 +122,7 @@ export const CompetitorRadar = () => {
</div> </div>
<button <button
onClick={() => addFromReal(f)} onClick={() => addFromReal(f)}
disabled={watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))} disabled={competitorSaving || watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))}
className="text-[11px] font-bold px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-700 hover:bg-indigo-50 disabled:opacity-40 shrink-0" className="text-[11px] font-bold px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-700 hover:bg-indigo-50 disabled:opacity-40 shrink-0"
> >
{watched.has(f.name.trim().toLocaleLowerCase('tr-TR')) ? 'İzleniyor' : 'Radara Ekle'} {watched.has(f.name.trim().toLocaleLowerCase('tr-TR')) ? 'İzleniyor' : 'Radara Ekle'}
@@ -145,12 +133,15 @@ export const CompetitorRadar = () => {
)} )}
</div> </div>
{competitorError&&<p role="alert" className="text-sm text-red-700">{competitorError}</p>}
{competitorSaving&&<p role="status" className="text-sm">Rakip listesi kaydediliyor…</p>}
{/* Add New Competitor Form */} {/* Add New Competitor Form */}
<form onSubmit={handleAddCompetitor} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs flex flex-col md:flex-row gap-3 items-end"> <form onSubmit={handleAddCompetitor} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs flex flex-col md:flex-row gap-3 items-end">
<div className="flex-1 space-y-1.5 w-full"> <div className="flex-1 space-y-1.5 w-full">
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">İzlenecek Rakip Firma Adı</label> <label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">İzlenecek Rakip Firma Adı</label>
<input <input
type="text" type="text"
aria-label="İzlenecek rakip firma adı" disabled={competitorSaving}
required required
value={newCompetitorName} value={newCompetitorName}
onChange={(e) => setNewCompetitorName(e.target.value)} onChange={(e) => setNewCompetitorName(e.target.value)}
@@ -162,6 +153,7 @@ export const CompetitorRadar = () => {
<div className="w-full md:w-64 space-y-1.5"> <div className="w-full md:w-64 space-y-1.5">
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Sektör</label> <label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Sektör</label>
<select <select
aria-label="Rakip sektör" disabled={competitorSaving}
value={newSector} value={newSector}
onChange={(e) => setNewSector(e.target.value)} onChange={(e) => setNewSector(e.target.value)}
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-900" className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-900"
@@ -175,7 +167,7 @@ export const CompetitorRadar = () => {
</div> </div>
<button <button
type="submit" type="submit" disabled={competitorSaving || !newCompetitorName.trim() || watched.has(newCompetitorName.trim().toLocaleLowerCase('tr-TR'))}
className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-500 text-white font-extrabold text-xs px-6 py-3 rounded-xl flex items-center justify-center gap-2 shadow-md transition-colors shrink-0" className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-500 text-white font-extrabold text-xs px-6 py-3 rounded-xl flex items-center justify-center gap-2 shadow-md transition-colors shrink-0"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -189,11 +181,13 @@ export const CompetitorRadar = () => {
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2"> <h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-indigo-600" /> Takipteki Rakip Firmalar ({competitorWatchlist.length}) <ShieldAlert className="w-4 h-4 text-indigo-600" /> Takipteki Rakip Firmalar ({competitorWatchlist.length})
</h3> </h3>
<span className="text-xs text-slate-500">Otomatik bildirimler aktif</span> <span className="text-xs text-slate-500">Yüklenen havuzdaki sonuçlar</span>
</div> </div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{competitorWatchlist.map((comp) => ( {competitorWatchlist.map((comp) => {
const stats=firms.find(f=>f.name.trim().toLocaleLowerCase('tr-TR')===comp.name.trim().toLocaleLowerCase('tr-TR'));
return (
<div key={comp.id} className="p-5 hover:bg-slate-50/80 transition-colors flex flex-col md:flex-row md:items-center justify-between gap-4"> <div key={comp.id} className="p-5 hover:bg-slate-50/80 transition-colors flex flex-col md:flex-row md:items-center justify-between gap-4">
{/* Info */} {/* Info */}
@@ -205,36 +199,32 @@ export const CompetitorRadar = () => {
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-slate-500 font-medium"> <div className="flex items-center gap-4 text-xs text-slate-500 font-medium">
<span>🏆 Kazandığı İhale: <strong className="text-slate-800">{comp.winCount}</strong></span> <span>🏆 Kazandığı İhale: <strong className="text-slate-800">{stats?.wins ?? 'Veri yok'}</strong></span>
<span>💰 Toplam Hacim: <strong className="text-emerald-700">₺{(comp.totalRevenue / 1000000).toFixed(1)}M</strong></span> <span>💰 Toplam Hacim: <strong className="text-emerald-700">{stats ? formatTL(stats.total) : 'Veri yok'}</strong></span>
<span>📊 Tahmini Pazar Payı: <strong className="text-indigo-900">%{comp.marketShare}</strong></span> <span>📊 Yüklü havuzdaki pay: <strong className="text-indigo-900">{stats ? `%${stats.share}` : 'Veri yok'}</strong></span>
</div> </div>
</div> </div>
{/* Notification Toggles & Actions */} {/* Notification Toggles & Actions */}
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<button <button
onClick={() => toggleAlert(comp.id, 'alertOnBid')} disabled title="Rakip teklif bildirimleri henüz etkin değil"
className={`text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-colors ${ className="text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 bg-slate-100 text-slate-400 border-slate-200"
comp.alertOnBid ? 'bg-amber-50 text-amber-800 border-amber-300' : 'bg-slate-100 text-slate-400 border-slate-200'
}`}
> >
<Bell className="w-3.5 h-3.5" /> <Bell className="w-3.5 h-3.5" />
<span>Teklif Verince Uyar</span> <span>Teklif uyarısı etkin değil</span>
</button> </button>
<button <button
onClick={() => toggleAlert(comp.id, 'alertOnWin')} disabled title="Rakip kazanım bildirimleri henüz etkin değil"
className={`text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-colors ${ className="text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 bg-slate-100 text-slate-400 border-slate-200"
comp.alertOnWin ? 'bg-emerald-50 text-emerald-800 border-emerald-300' : 'bg-slate-100 text-slate-400 border-slate-200'
}`}
> >
<Award className="w-3.5 h-3.5" /> <Award className="w-3.5 h-3.5" />
<span>Kazanırsa Uyar</span> <span>Kazanım uyarısı etkin değil</span>
</button> </button>
<button <button
onClick={() => removeCompetitor(comp.id)} disabled={competitorSaving} aria-label={`${comp.name} radardan çıkar`} onClick={() => removeCompetitor(comp.id)}
className="p-2 text-slate-400 hover:text-red-600 rounded-lg transition-colors" className="p-2 text-slate-400 hover:text-red-600 rounded-lg transition-colors"
title="Radardan Çıkar" title="Radardan Çıkar"
> >
@@ -243,7 +233,7 @@ export const CompetitorRadar = () => {
</div> </div>
</div> </div>
))} );})}
</div> </div>
</div> </div>
+17 -5
View File
@@ -166,7 +166,7 @@ export const AppProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false); notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus(''); aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
dataRevisions.current={};setTeamError(null); dataRevisions.current={};setTeamError(null);setCompetitorError(null);
setVeriSenkron(false); setPlan(null); setSaveError(null); setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser)); setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -351,9 +351,21 @@ export const AppProvider = ({ children }) => {
// Enterprise Module States // Enterprise Module States
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı) // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı)
const [competitorWatchlist, setCompetitorWatchlist] = useState([]); const [competitorWatchlist, setCompetitorWatchlist] = useState([]);
useEffect(() => { const competitorSaveLock=useRef(false);
if (veriSenkron) ilkYazmaDegil('rakip_izleme', competitorWatchlist); const [competitorSaving,setCompetitorSaving]=useState(false);
}, [competitorWatchlist, veriSenkron]); 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ı) --- // --- Favori kurumlar (sunucu kaydı) ---
const [favoriteKurumlar, setFavoriteKurumlar] = useState([]); const [favoriteKurumlar, setFavoriteKurumlar] = useState([]);
@@ -810,7 +822,7 @@ export const AppProvider = ({ children }) => {
aiAnalyzing, aiAnalyzing,
aiJobStatus, aiJobStatus,
competitorWatchlist, competitorWatchlist,
setCompetitorWatchlist, saveCompetitorWatchlist,competitorSaving,competitorError,
vaultDocuments, vaultDocuments,
setVaultDocuments, setVaultDocuments,
reminderSettings, reminderSettings,
+10
View File
@@ -239,3 +239,13 @@ test('favorite institutions require a version and retain current values after a
assert.deepEqual((await request('/api/me/data')).data.veri[anahtar],['QA Kurum','QA Diğer']); assert.deepEqual((await request('/api/me/data')).data.veri[anahtar],['QA Kurum','QA Diğer']);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:updated.data.revision})).status,200); assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:updated.data.revision})).status,200);
}); });
test('competitor watchlist keeps the saved roster when a stale or invalid write is rejected',async()=>{
const anahtar='rakip_izleme',item={id:'qa-radar',name:'QA Medikal',sector:'Sağlık'};
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[item]})).status,428);
const first=await request('/api/me/data','PUT',{anahtar,deger:[item],revision:null});assert.equal(first.status,200);
for(const deger of [{},[{}],[{...item,sector:{}}],[item,{...item,id:'other',name:' qa medikal '}]])
assert.equal((await request('/api/me/data','PUT',{anahtar,deger,revision:first.data.revision})).status,400);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:null})).status,409);
assert.deepEqual((await request('/api/me/data')).data.veri[anahtar],[item]);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:first.data.revision})).status,200);
});