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.
- 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ı.
### 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.
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))
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)))))
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);
+1 -1
View File
@@ -60,7 +60,7 @@ export const TopNavigation = () => {
{ id: 'sinir', label: 'Sınır Değer', icon: Calculator },
{ id: 'fiyat', label: 'Fiyat Analizi', icon: TrendingUp },
{ 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: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 },
{ 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 { useApp } from '../../context/AppContext';
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';
export const CompetitorRadar = () => {
const { competitorWatchlist, setCompetitorWatchlist, setActiveTab, tenders } = useApp();
const { competitorWatchlist, saveCompetitorWatchlist, competitorSaving, competitorError, setActiveTab, tenders } = useApp();
const [newCompetitorName, setNewCompetitorName] = useState('');
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 addFromReal = (f) => {
if (watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))) return;
setCompetitorWatchlist((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 },
saveCompetitorWatchlist((prev) => [
{ 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,
]);
confetti({ particleCount: 40 });
};
const handleAddCompetitor = (e) => {
const handleAddCompetitor = async (e) => {
e.preventDefault();
if (!newCompetitorName.trim()) return;
if (!newCompetitorName.trim() || watched.has(newCompetitorName.trim().toLocaleLowerCase('tr-TR'))) return;
const newComp = {
id: 'cw-' + Date.now(),
name: newCompetitorName,
id: 'cw-' + crypto.randomUUID(),
name: newCompetitorName.trim(),
sector: newSector,
winCount: 0,
totalRevenue: 0,
marketShare: 5,
status: 'Aktif İzleniyor',
alertOnBid: true,
alertOnWin: true
};
setCompetitorWatchlist(prev => [newComp, ...prev]);
setNewCompetitorName('');
confetti({ particleCount: 50 });
};
const toggleAlert = (id, field) => {
setCompetitorWatchlist(prev => prev.map(c => c.id === id ? { ...c, [field]: !c[field] } : c));
if(await saveCompetitorWatchlist(prev => [newComp, ...prev]))setNewCompetitorName('');
};
const removeCompetitor = (id) => {
setCompetitorWatchlist(prev => prev.filter(c => c.id !== id));
saveCompetitorWatchlist(prev => prev.filter(c => c.id !== id));
};
return (
@@ -61,12 +50,12 @@ export const CompetitorRadar = () => {
</div>
<div>
<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">
Proaktif Takip
Kayıtlı Firmalar
</span>
</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>
@@ -119,8 +108,7 @@ export const CompetitorRadar = () => {
</span>
)}
<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ı panoya kopyalanır (EKAP sorguyu API'den vermiyor)"
title="KİK yasaklılar sorgusunu açar; firma adını sorguya girin"
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
</a>
@@ -134,7 +122,7 @@ export const CompetitorRadar = () => {
</div>
<button
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"
>
{watched.has(f.name.trim().toLocaleLowerCase('tr-TR')) ? 'İzleniyor' : 'Radara Ekle'}
@@ -145,12 +133,15 @@ export const CompetitorRadar = () => {
)}
</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 */}
<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">
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">İzlenecek Rakip Firma Adı</label>
<input
type="text"
aria-label="İzlenecek rakip firma adı" disabled={competitorSaving}
required
value={newCompetitorName}
onChange={(e) => setNewCompetitorName(e.target.value)}
@@ -162,6 +153,7 @@ export const CompetitorRadar = () => {
<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>
<select
aria-label="Rakip sektör" disabled={competitorSaving}
value={newSector}
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"
@@ -175,7 +167,7 @@ export const CompetitorRadar = () => {
</div>
<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"
>
<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">
<ShieldAlert className="w-4 h-4 text-indigo-600" /> Takipteki Rakip Firmalar ({competitorWatchlist.length})
</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 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">
{/* Info */}
@@ -205,36 +199,32 @@ export const CompetitorRadar = () => {
</span>
</div>
<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>💰 Toplam Hacim: <strong className="text-emerald-700">₺{(comp.totalRevenue / 1000000).toFixed(1)}M</strong></span>
<span>📊 Tahmini Pazar Payı: <strong className="text-indigo-900">%{comp.marketShare}</strong></span>
<span>🏆 Kazandığı İhale: <strong className="text-slate-800">{stats?.wins ?? 'Veri yok'}</strong></span>
<span>💰 Toplam Hacim: <strong className="text-emerald-700">{stats ? formatTL(stats.total) : 'Veri yok'}</strong></span>
<span>📊 Yüklü havuzdaki pay: <strong className="text-indigo-900">{stats ? `%${stats.share}` : 'Veri yok'}</strong></span>
</div>
</div>
{/* Notification Toggles & Actions */}
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => toggleAlert(comp.id, 'alertOnBid')}
className={`text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-colors ${
comp.alertOnBid ? 'bg-amber-50 text-amber-800 border-amber-300' : 'bg-slate-100 text-slate-400 border-slate-200'
}`}
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 bg-slate-100 text-slate-400 border-slate-200"
>
<Bell className="w-3.5 h-3.5" />
<span>Teklif Verince Uyar</span>
<span>Teklif uyarısı etkin değil</span>
</button>
<button
onClick={() => toggleAlert(comp.id, 'alertOnWin')}
className={`text-xs font-extrabold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-colors ${
comp.alertOnWin ? 'bg-emerald-50 text-emerald-800 border-emerald-300' : 'bg-slate-100 text-slate-400 border-slate-200'
}`}
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 bg-slate-100 text-slate-400 border-slate-200"
>
<Award className="w-3.5 h-3.5" />
<span>Kazanırsa Uyar</span>
<span>Kazanım uyarısı etkin değil</span>
</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"
title="Radardan Çıkar"
>
@@ -243,7 +233,7 @@ export const CompetitorRadar = () => {
</div>
</div>
))}
);})}
</div>
</div>
+17 -5
View File
@@ -166,7 +166,7 @@ export const AppProvider = ({ children }) => {
useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
dataRevisions.current={};setTeamError(null);
dataRevisions.current={};setTeamError(null);setCompetitorError(null);
setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -351,9 +351,21 @@ export const AppProvider = ({ children }) => {
// 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]);
const competitorSaveLock=useRef(false);
const [competitorSaving,setCompetitorSaving]=useState(false);
const [competitorError,setCompetitorError]=useState(null);
const saveCompetitorWatchlist=async change=>{
if(!authUser||!veriSenkron){setCompetitorError('Hesap verileri yüklenmeden rakip listesi kaydedilemez.');return false;}
if(competitorSaveLock.current)return false;
const session=getToken();competitorSaveLock.current=true;setCompetitorSaving(true);setCompetitorError(null);
try {
const next=typeof change==='function'?change(competitorWatchlist):change;
const saved=await setUserData('rakip_izleme',next,dataRevisions.current.rakip_izleme ?? null);
if(getToken()!==session)return false;
dataRevisions.current.rakip_izleme=saved.revision;setCompetitorWatchlist(next);return true;
}catch(error){if(getToken()===session)setCompetitorError(error.message);return false;}
finally{competitorSaveLock.current=false;setCompetitorSaving(false);}
};
// --- Favori kurumlar (sunucu kaydı) ---
const [favoriteKurumlar, setFavoriteKurumlar] = useState([]);
@@ -810,7 +822,7 @@ export const AppProvider = ({ children }) => {
aiAnalyzing,
aiJobStatus,
competitorWatchlist,
setCompetitorWatchlist,
saveCompetitorWatchlist,competitorSaving,competitorError,
vaultDocuments,
setVaultDocuments,
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.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);
});