feat: İhaleAB'den taşınan özellikler — MCP sunucusu, RSS, haftalık brief, teklif asistanı, yeterlilik rozeti, 5 yeni kırmızı bayrak

- /mcp (JSON-RPC, ih_live_ anahtarı): search_tenders, get_tender, search_results, search_items, upcoming_deadlines
- /api/feeds/tenders.rss (anahtarlı) ve /api/feeds/sonuc.rss (açık)
- Haftalık brief (Pazartesi 08:00, Telegram + e-posta), /api/me/brief tercihi + önizleme, sağ panelde yaklaşan ihale tarihleri
- Detayda Teklif Asistanı sekmesi: belgeye sor (atıflı), taslak denetimi, taslak üretimi (analyzeSpec zinciri)
- Derin analiz belge listesi × Doküman Deposu eşleştirme rozeti
- Kırmızı bayrak: kısa süre, hafta sonu tarih, geç sözleşme, kazananı gizli, DT parçalama şüphesi
- Kullanım kılavuzu güncellendi

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 02:23:41 +03:00
co-authored by Claude Fable 5.1
parent fabff18fe9
commit 48f0c48732
18 changed files with 940 additions and 10 deletions
+2
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { YeterlilikRozeti } from './YeterlilikRozeti';
import {
AlertOctagon, AlertTriangle, Eye, CalendarClock, ClipboardList, Route,
HelpCircle, Coins, Target, CheckSquare, Square, Package,
@@ -206,6 +207,7 @@ export const DeepAnalysisReport = ({ results, tender }) => {
</span>
{b.note && <span className="text-[11px] text-slate-500 leading-snug block mt-0.5">{b.note}</span>}
</span>
<YeterlilikRozeti belge={b.document} />
{b.mandatory === false
? <span className="text-[10px] font-bold text-slate-500 bg-slate-200 px-1.5 py-0.5 rounded shrink-0">İsteğe bağlı</span>
: <span className="text-[10px] font-bold text-red-700 bg-red-100 px-1.5 py-0.5 rounded shrink-0">Zorunlu</span>}
+13 -6
View File
@@ -12,13 +12,15 @@ const KILAVUZ = {
baslik: 'Ana İhale Listesi',
ne: 'Türkiye kamu ihaleleri (ilan.gov.tr + EKAP), doğrudan temin ve geçmiş sonuçlar tek listede. Kartta ihale/ilan tarihi, sektör, CPV, teklif cetveli kalemleri ve şirket uyum skoru görünür.',
adimlar: [
'Sağ paneldeki \'Yaklaşan İhale Tarihleri\' kutusu yıldızladığınız / panoya aldığınız ihalelerin 14 günlük geri sayımını gösterir (≤3 gün kırmızı, ≤7 gün turuncu); tıklayınca detay açılır.',
'Detaydaki \'Teklif Asistanı\' sekmesi: Belgeye Sor (dokümanda teminat, teslim, ceza gibi soruları [n] atıflı yanıtlar), Taslağı Denetle (teklif metninizi gerekliliklerle karşılaştırır: karşılanıyor / eksik / belirsiz), Taslak Üret (kapak / teknik / ticari / tam dosya; bilinmeyen yerler [DOLDURUN]).',
'Üstteki kutuya ürün, kurum ya da İKN yazın — arama başlıkla birlikte teklif cetveli kalemlerinde de yapılır.',
'Başlıkta geçmeyen ürününüz için sarı "ÜRÜN ARA" düğmesini kullanın: EKAP dokümanlarının içinde arar.',
'Sol menüden il, tür, sektör, CPV/OKAS ile daraltın; "Sadece açık ihaleler" varsayılan açıktır.',
'Sıralamayı "Şirketime En Uyumlu" yapın — profilinizdeki ürünlere göre puanlar.',
'Karta tıklayıp detayda dokümanı yükleyin: kalemler otomatik çıkarılır, derin analiz ve yol haritası oradan.',
],
ipuclari: ['Yıldız = takip. Takipteki ihalede zeyilname/düzeltme ilanı çıkarsa ve sonuçlanınca bildirim gelir.', '"%N Uyumlu" rozetine gelince neden eşleştiği görünür.'],
ipuclari: ['Teklif Asistanı doküman metnini EKAP\'tan alır; alamazsa sekmedeki \'Doküman yükle\' ile PDF\'i verin.', 'Yıldız = takip. Takipteki ihalede zeyilname/düzeltme ilanı çıkarsa ve sonuçlanınca bildirim gelir.', '"%N Uyumlu" rozetine gelince neden eşleştiği görünür.'],
},
bulten: { baslik: 'Günlük Bülten', ne: 'Bugün eklenen ihalelerin özeti.', adimlar: ['Sabah bir bakış için tasarlandı; e-posta bülteni için "Mobil PWA → Bildirimler"i kullanın.'], ipuclari: [] },
sonuc: { baslik: 'Sonuç Sorgula', ne: 'EKAP sonuç ilanlarından kazanan firma, sözleşme bedeli, teklif sayısı.', adimlar: ['Firma, idare veya il ile filtreleyin.', 'Rakibinizin hangi idarelerden ne bedelle aldığını görün; "Rakip Radar"a ekleyin.'], ipuclari: ['Kısmi ihalelerde bedel kısım başına, yaklaşık maliyet tüm ihale içindir — kırım oranı bu yüzden yalnız kısımsız ihalelerde gösterilir.'] },
@@ -42,20 +44,25 @@ const KILAVUZ = {
dokuman_deposu: {
baslik: 'Doküman Deposu',
ne: 'Şirket belgeleriniz (imza sirküleri, oda kaydı…) ve ihale dokümanları sunucuda saklanır.',
adimlar: ['Her belgeye son kullanma tarihi girin — süresi dolan/30 gün içinde dolacak belgeler kırmızı/sarı uyarıyla listelenir, girişte bildirim gelir.', '"İhale Dokümanlarını Toplu Yükle": EKAP\'tan indirdiğiniz şartnameleri hepsini birden seçin; her dosyanın İKN\'si metninden bulunur, teklif cetveli kalemleri otomatik çıkarılır.', 'Taranmış (görüntü) PDF\'ler de okunur — OCR otomatik devreye girer.'],
ipuclari: ['Kalemleri çıkarılan ihale herkesin kartında kalemleriyle görünür; ilk yükleyen herkes için açar.'],
adimlar: [
'Derin analizdeki \'Belge Kontrol Listesi\' her madde için depodaki evrakla eşleşir: Depoda hazır / Yakında dolacak (≤30 gün) / Süresi dolmuş / Depoda yok — rozetin üzerine gelince hangi belge ve kaç gün kaldığı görünür.','Her belgeye son kullanma tarihi girin — süresi dolan/30 gün içinde dolacak belgeler kırmızı/sarı uyarıyla listelenir, girişte bildirim gelir.', '"İhale Dokümanlarını Toplu Yükle": EKAP\'tan indirdiğiniz şartnameleri hepsini birden seçin; her dosyanın İKN\'si metninden bulunur, teklif cetveli kalemleri otomatik çıkarılır.', 'Taranmış (görüntü) PDF\'ler de okunur — OCR otomatik devreye girer.'],
ipuclari: ['Kategoriyi doğru seçin (Yasal / Kalite / Mali / Resmi / Teknik): eşleştirme önce kategoriye, sonra belge adına bakar.', 'Kalemleri çıkarılan ihale herkesin kartında kalemleriyle görünür; ilk yükleyen herkes için açar.'],
},
analitik_dashboard: { baslik: 'Analitik Dashboard', ne: 'Sektör, il ve kurum bazlı dağılımlar, hacim ve rekabet grafikleri.', adimlar: ['Filtreler listedekiyle aynıdır; sol menü seçimleri buraya da yansır.'], ipuclari: [] },
kirmizi_bayrak: { baslik: 'Kırmızı Bayrak', ne: 'Şeffaflık sinyalleri: tek teklifli ihaleler, düşük kırım, tahmin üstü bedel, tedarikçi yoğunlaşması.', adimlar: ['Seviye (Yüksek/Orta) ile süzün; ihaleye tıklayıp detaya gidin.'], ipuclari: [] },
kirmizi_bayrak: { baslik: 'Kırmızı Bayrak', ne: 'Şeffaflık sinyalleri: tek teklifli ihaleler, düşük kırım, tahmin üstü bedel, tedarikçi yoğunlaşması.', adimlar: [
'Yeni kurallar: Kısa süre (ilan→ihale <7 gün), Hafta sonu tarih, Geç sözleşme (ihaleden 180+ gün sonra), Kazananı gizli (sonuç var ad yok), Parçalama şüphesi (aynı idare + sektörde 30 günde 3+ doğrudan temin — 4734 md. 5 / 22-d).','Seviye (Yüksek/Orta) ile süzün; ihaleye tıklayıp detaya gidin.'], ipuclari: ['Parçalama şüphesi bayrağı yalnız doğrudan temin verisi (DT worker) açıksa dolar.', ] },
kik_kararlari: { baslik: 'KİK Kurul Kararları', ne: 'İtirazen şikâyet kararlarında arama.', adimlar: ['Konu, idare veya başvuran ile arayın; benzer uyuşmazlıklarda Kurul\'un ne dediğini görün.'], ipuclari: [] },
ted_eu: { baslik: 'AB İhaleleri (TED)', ne: 'Türkiye\'deki idarelerin AB\'de yayımladığı ve Türk firmalarına açık AB ihaleleri.', adimlar: ['"Açık AB İhaleleri" sekmesiyle teklif verilebilir olanları listeleyin.'], ipuclari: [] },
planlar: { baslik: 'Planlar', ne: 'Pro ve Kurumsal planlar, yıllık indirim.', adimlar: ['Stripe ile ödeme; iptal dönem sonunda geçerli olur.'], ipuclari: [] },
api_webhooks: { baslik: 'API & Webhooks', ne: 'Kendi sistemlerinize ihale akışı.', adimlar: ['API anahtarı üretin; webhook adresinize yeni ihaleler gönderilir.'], ipuclari: [] },
api_webhooks: { baslik: 'API & Webhooks', ne: 'Kendi sistemlerinize ihale akışı.', adimlar: [
'MCP: bir API anahtarı üretin, karttaki Claude Code komutunu veya mcp.json\'u kopyalayıp anahtarı yerine yazın; asistanınız search_tenders, get_tender, search_results, search_items, upcoming_deadlines araçlarını kullanır.',
'RSS: /api/feeds/tenders.rss?key=… adresini (il, sektor, q filtreleri) Feedly / Outlook / Slack RSS\'e ekleyin; sonuç ilanları için /api/feeds/sonuc.rss anahtarsızdır.','API anahtarı üretin; webhook adresinize yeni ihaleler gönderilir.'], ipuclari: [] },
ai_ayarlari: { baslik: 'AI Model Ayarları', ne: 'Analizlerde kullanılacak model zinciri (yedekli).', adimlar: ['Sırayı sürükleyerek değiştirin; ilk başarılı model sonucu döner.'], ipuclari: [] },
pwa_settings: {
baslik: 'Mobil PWA & Bildirimler',
ne: 'Uygulamayı telefona kurma, e-posta ve Telegram bildirimleri.',
adimlar: ['E-posta aboneliğinde konu/il/sektör seçin; yalnız eşleşen yeni ihaleler gelir.', 'Telegram: bota gösterilen 6 haneli kodu yazıp "Doğrula" deyin — ihale özeti, zeyilname ve sonuç bildirimleri Telegram\'a da gider.'],
adimlar: [
'Haftalık Brief (Pazartesi 08:00): bu hafta yapılacak takipli ihaleler ve geçen hafta sonuçlanan takipliler tek mesajda (Telegram + e-posta). \'Önizle\' ile şimdiki içeriği görün; anahtarla kapatın.','E-posta aboneliğinde konu/il/sektör seçin; yalnız eşleşen yeni ihaleler gelir.', 'Telegram: bota gösterilen 6 haneli kodu yazıp "Doğrula" deyin — ihale özeti, zeyilname ve sonuç bildirimleri Telegram\'a da gider.'],
ipuclari: ['Telegram için sunucuda bot tokenı tanımlı olmalı; yoksa ekran kurulum adımlarını gösterir.'],
},
smart_filter_builder: { baslik: 'Akıllı Filtre', ne: 'Kaydedilebilir çok ölçütlü filtre.', adimlar: ['Ölçütleri birleştirip kaydedin; sol menüden tek tıkla uygulanır.'], ipuclari: [] },
+4
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { YaklasanSonTarihler } from './YaklasanSonTarihler';
import { firmStats, companyStats } from '../services/analytics';
import { Briefcase, FileText, Calendar, History, Trophy, Eye, Kanban, Radar, ArrowRight, Users, Plus, Trash2 } from 'lucide-react';
@@ -61,6 +62,9 @@ export const RightSidebar = () => {
</div>
</div>
{/* Yaklaşan ihale tarihleri — takip/panodaki ihaleler, 14 gün */}
<YaklasanSonTarihler />
{/* İhale Durumlarım (Kanban Entrance) */}
<div className="bg-white rounded-xl border border-slate-200 shadow-xs p-4 space-y-3">
<div className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider text-slate-800 border-b border-slate-100 pb-2">
+143
View File
@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
import { getToken } from '../services/auth';
import { listDocuments } from '../services/account';
/**
* Teklif Asistanı sekmesi — üç araç (İhaleAB'den taşındı):
* 1) Belgeye Sor: doküman/ilan metninde soru → [n] atıflı yanıt
* 2) Taslağı Denetle: teklif taslağı × gereklilikler → karşılanıyor / eksik / belirsiz
* 3) Taslak Üret: kapak / teknik / ticari / tam dosya (rakam uydurmaz, [DOLDURUN])
* Doküman metni sunucuda EKAP'tan alınır; yoksa buradan .txt/.pdf metni yapıştırılır.
*/
const post = async (url, body) => {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: JSON.stringify(body) });
const d = await r.json().catch(() => ({}));
if (!r.ok || d.success === false) throw new Error(d.error || `HTTP ${r.status}`);
return d;
};
const ORNEKLER = ['Geçici teminat oranı ve süresi nedir?', 'Teslim süresi kaç gün?', 'Gecikme cezası ne kadar?', 'Hangi belgeler istenmiş?', 'Numune isteniyor mu?'];
const DURUM = { karsilaniyor: ['Karşılanıyor', 'bg-emerald-100 text-emerald-800 border-emerald-200'], eksik: ['Eksik', 'bg-red-100 text-red-800 border-red-200'], belirsiz: ['Belirsiz', 'bg-amber-100 text-amber-800 border-amber-200'] };
export const TeklifAsistani = ({ tender }) => {
const [arac, setArac] = useState('sor');
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
const [soru, setSoru] = useState('');
const [cevap, setCevap] = useState(null);
const [taslak, setTaslak] = useState('');
const [denetim, setDenetim] = useState(null);
const [tur, setTur] = useState('tam');
const [bolumler, setBolumler] = useState([]);
const [mesgul, setMesgul] = useState(false);
const [hata, setHata] = useState(null);
const calistir = async (fn) => { setMesgul(true); setHata(null); try { await fn(); } catch (e) { setHata(e.message); } finally { setMesgul(false); } };
const sor = (q) => calistir(async () => { setCevap(await post('/api/ai/belge-sor', { ikn: tender.ikn, soru: q, text: metin || undefined, tender })); });
const denetle = () => calistir(async () => { setDenetim(await post('/api/ai/teklif-denetim', { ikn: tender.ikn, taslak, tender, text: metin || undefined })); });
const uret = () => calistir(async () => {
const belgeler = await listDocuments().then((d) => (d.belgeler || []).map((b) => ({ ad: b.ad, kategori: b.kategori }))).catch(() => []);
setBolumler((await post('/api/ai/teklif-taslak', { ikn: tender.ikn, tur, tender, belgeler, text: metin || undefined })).bolumler || []);
});
const kopyala = (t) => navigator.clipboard?.writeText(t).catch(() => {});
const dosyaOku = async (f) => {
if (!f) return;
try {
const { extractText } = await import('../services/pdfText');
const r = await extractText(f);
setMetin(r.text || '');
} catch (e) { setHata('Dosya okunamadı: ' + e.message); }
};
const Sekme = ({ id, icon: Icon, children }) => (
<button type="button" onClick={() => setArac(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold border ${arac === id ? 'bg-violet-600 text-white border-violet-600' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'}`}>
<Icon className="w-3.5 h-3.5" /> {children}
</button>
);
return (
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Sekme id="sor" icon={MessageSquareText}>Belgeye Sor</Sekme>
<Sekme id="denet" icon={ClipboardCheck}>Taslağı Denetle</Sekme>
<Sekme id="uret" icon={PenLine}>Taslak Üret</Sekme>
<label className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-600 cursor-pointer border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">
<Upload className="w-3.5 h-3.5" /> {metin ? `Doküman metni yüklendi (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'}
<input type="file" accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} />
</label>
</div>
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtlar yalnızca belgeden; rakamlar aynen aktarılır, model uydurmaz.</p>
{hata && <p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2">{hata}</p>}
{arac === 'sor' && (
<div className="space-y-3">
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); if (soru.trim().length >= 3) sor(soru); }}>
<input value={soru} onChange={(e) => setSoru(e.target.value)} placeholder="Ör. Geçici teminat oranı nedir?" className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm" />
<button type="submit" disabled={mesgul} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 rounded-lg">{mesgul ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Sor'}</button>
</form>
{!cevap && <div className="flex flex-wrap gap-1.5">{ORNEKLER.map((o) => <button key={o} type="button" onClick={() => { setSoru(o); sor(o); }} className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50">{o}</button>)}</div>}
{cevap && (
<div className="space-y-2">
<p className="text-sm text-slate-800 whitespace-pre-wrap leading-relaxed">{cevap.cevap}</p>
{cevap.kaynaklar?.map((k) => (
<details key={k.n} className="rounded-lg border border-slate-200 bg-slate-50 text-xs">
<summary className="cursor-pointer px-2 py-1 font-semibold text-slate-700">[{k.n}] kaynak parça</summary>
<pre className="whitespace-pre-wrap px-2 pb-2 text-[11px] text-slate-600 font-sans">{k.alinti}</pre>
</details>
))}
{cevap.kaynak && <p className="text-[10px] text-slate-400">Kaynak: {cevap.kaynak}</p>}
</div>
)}
</div>
)}
{arac === 'denet' && (
<div className="space-y-3">
<textarea value={taslak} onChange={(e) => setTaslak(e.target.value)} rows={7} placeholder="Teklif mektubu / teknik teklif metninizi yapıştırın (en az 200 karakter)…" className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm" />
<button type="button" onClick={denetle} disabled={mesgul || taslak.trim().length < 200} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-1.5">
{mesgul ? <Loader2 className="w-4 h-4 animate-spin" /> : <ClipboardCheck className="w-4 h-4" />} Denetle
</button>
{denetim && (
<div className="space-y-2 rounded-xl border border-slate-200 p-3">
<div className="flex items-baseline gap-3">
<span className={`text-2xl font-black ${denetim.eksikler.length ? 'text-red-700' : 'text-emerald-700'}`}>%{denetim.uyum_yuzde}</span>
<span className="text-xs text-slate-500">{denetim.maddeler.length} gereklilik · {denetim.eksikler.length} eksik</span>
</div>
{denetim.ozet && <p className="text-sm text-slate-700">{denetim.ozet}</p>}
<ul className="space-y-1.5">
{denetim.maddeler.map((m, i) => (
<li key={i} className={`rounded-lg border p-2 text-xs ${DURUM[m.durum][1]}`}>
<div className="font-semibold">{m.gereklilik} <span className="ml-1 rounded bg-white/70 px-1 py-0.5 text-[10px]">{DURUM[m.durum][0]}</span></div>
{m.kanit && <p className="mt-1 italic opacity-80">“{m.kanit}”</p>}
{m.oneri && <p className="mt-1"><b>Öneri:</b> {m.oneri}</p>}
</li>
))}
</ul>
</div>
)}
</div>
)}
{arac === 'uret' && (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<select value={tur} onChange={(e) => setTur(e.target.value)} className="bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 text-sm">
<option value="kapak">Teklif mektubu (kapak)</option><option value="teknik">Teknik teklif</option><option value="ticari">Ticari teklif</option><option value="tam">Tam dosya</option>
</select>
<button type="button" onClick={uret} disabled={mesgul} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-1.5">
{mesgul ? <Loader2 className="w-4 h-4 animate-spin" /> : <PenLine className="w-4 h-4" />} {bolumler.length ? 'Yeniden üret' : 'Taslağı üret'}
</button>
{bolumler.length > 0 && <button type="button" onClick={() => kopyala(bolumler.map((b) => `${b.baslik}\n\n${b.icerik}`).join('\n\n\n'))} className="text-xs text-slate-600 border border-slate-200 rounded-lg px-2 py-1.5 flex items-center gap-1 hover:bg-slate-50"><Copy className="w-3.5 h-3.5" /> Tümünü kopyala</button>}
</div>
{bolumler.map((b, i) => (
<div key={i} className="space-y-1">
<input value={b.baslik} onChange={(e) => setBolumler((p) => p.map((x, k) => (k === i ? { ...x, baslik: e.target.value } : x)))} className="w-full border border-slate-200 rounded-lg px-2 py-1 text-sm font-bold" />
<textarea value={b.icerik} onChange={(e) => setBolumler((p) => p.map((x, k) => (k === i ? { ...x, icerik: e.target.value } : x)))} rows={Math.min(14, Math.max(4, b.icerik.split('\n').length + 1))} className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm leading-relaxed" />
</div>
))}
</div>
)}
</div>
);
};
+6 -2
View File
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { TenderDocuments } from './TenderDocuments';
import { TeklifAsistani } from './TeklifAsistani';
import { TeklifCetveliKalemleri } from './TeklifCetveliKalemleri';
import {
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
Send, Stamp, ArrowLeft, ExternalLink, Printer
} from 'lucide-react';
Send, Stamp, ArrowLeft, ExternalLink, Printer, PenLine } from 'lucide-react';
import confetti from 'canvas-confetti';
export const TenderDetailModal = () => {
@@ -178,6 +178,7 @@ END:VCALENDAR`;
{ id: 'pdf_stamp', label: 'Birim Fiyat Cetveli (PDF & Kaşe)', icon: Stamp, badge: 'İnteraktif' },
{ id: 'ai_assistant', label: 'Yapay Zeka Asistanı', icon: Sparkles, badge: '%94 Uyum' },
{ id: 'checklist', label: 'Hazırlık Checklist', icon: CheckSquare },
{ id: 'teklif_asistani', label: 'Teklif Asistanı', icon: PenLine, badge: 'AI' },
].map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -550,6 +551,9 @@ END:VCALENDAR`;
</div>
)}
{/* Tab 5: Teklif Asistanı — belgeye sor / taslak denetimi / taslak üretimi */}
{activeTab === 'teklif_asistani' && <TeklifAsistani tender={tender} />}
{/* Tab 4: Readiness Checklist */}
{activeTab === 'checklist' && (
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-4">
+46
View File
@@ -0,0 +1,46 @@
import React, { useMemo } from 'react';
import { CalendarClock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { parseDateTR } from '../services/analytics';
/**
* Sağ panel: takipteki / panodaki ihalelerden 14 gün içinde yapılacaklar.
* Sunucuya gitmez — liste zaten bellekte. ≤3 gün kırmızı, ≤7 turuncu.
*/
export const YaklasanSonTarihler = ({ gun = 14 }) => {
const { tenders, setActiveTenderDetail } = useApp();
const liste = useMemo(() => {
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
const sinir = new Date(bugun.getTime() + gun * 86400000);
return tenders
.filter((t) => t.takipte || t.kanbanStatus || t.gorev)
.map((t) => ({ t, d: parseDateTR(t.tarih) }))
.filter(({ d }) => d && d >= bugun && d <= sinir)
.sort((a, b) => a.d - b.d)
.map(({ t, d }) => ({ t, kalan: Math.round((d - bugun) / 86400000) }))
.slice(0, 8);
}, [tenders, gun]);
const renk = (g) => (g <= 3 ? 'bg-red-600 text-white' : g <= 7 ? 'bg-amber-500 text-white' : 'bg-slate-200 text-slate-800');
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-xs overflow-hidden">
<div className="bg-slate-50 border-b border-slate-100 px-4 py-3">
<span className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider text-slate-800">
<CalendarClock className="w-4 h-4 text-amber-500" /> Yaklaşan İhale Tarihleri
</span>
</div>
<div className="p-2 space-y-1">
{liste.length === 0 ? (
<p className="px-2 py-3 text-xs text-slate-500">{gun} gün içinde yapılacak takipli ihale yok. Yıldızla takibe alın; burada geri sayım başlar.</p>
) : liste.map(({ t, kalan }) => (
<button key={t.id} type="button" onClick={() => setActiveTenderDetail?.(t)}
className="w-full flex items-center justify-between gap-2 rounded-lg p-2 text-xs hover:bg-slate-50 text-left">
<span className="min-w-0 truncate font-medium text-slate-800" title={t.baslik}>{t.baslik}</span>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${renk(kalan)}`}>{kalan === 0 ? 'BUGÜN' : `${kalan} gün`}</span>
</button>
))}
</div>
</div>
);
};
+62
View File
@@ -0,0 +1,62 @@
import React, { useEffect, useState } from 'react';
import { listDocuments } from '../services/account';
/**
* Derin analiz belge kontrol listesi × Doküman Deposu eşleştirmesi.
* Belge adı/kategori sözcüklerine göre depodaki evrakı bulur: Hazır /
* Yakında dolacak (≤30 gün) / Süresi dolmuş / Eksik. Depo listesi bir kez
* çekilip modül düzeyinde paylaşılır.
*/
const KATEGORI_DESEN = {
'Yasal Belgeler': /imza|sirk|vekalet|yetki|sicil|gazete|oda\s*kay|faaliyet|ticaret|kuruluş|adli|sabıka|iflas|konkordato/i,
'Kalite Belgeleri': /iso|kalite|tse|ce\b|sertifika|belgelendirme|9001|14001|45001|27001|hizmet\s*yeterlilik/i,
'Mali Belgeler': /bilanço|mali|gelir|ciro|banka|referans|teminat|vergi\s*borc|sgk|prim|sosyal\s*güvenlik/i,
'Resmi Kayıtlar': /vergi\s*levha|kimlik|ekap\s*kay|kayıt\s*belge|mersis|ticaret\s*sicil\s*no/i,
'Teknik Belgeler': /teknik|katalog|test\s*rapor|numune|uygunluk|garanti|referans\s*iş|iş\s*deneyim|iş\s*bitirme|kapasite|üretim/i,
};
let depoCache = null;
let depoPromise = null;
function depoyuGetir() {
if (depoCache) return Promise.resolve(depoCache);
if (!depoPromise) depoPromise = listDocuments().then((d) => (depoCache = d.belgeler || d.documents || [])).catch(() => (depoCache = []));
return depoPromise;
}
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR').replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ').trim();
function benzerlik(a, b) {
const x = norm(a).split(' ').filter((k) => k.length >= 4);
if (!x.length) return 0;
const y = norm(b);
return x.filter((k) => y.includes(k)).length / x.length;
}
export function eslestir(belge, depo, bugun = new Date()) {
const kat = Object.entries(KATEGORI_DESEN).find(([, re]) => re.test(norm(belge)))?.[0] || null;
const aday = depo
.map((d) => ({ d, puan: (kat && d.kategori === kat ? 1 : 0) + benzerlik(belge, d.ad) }))
.filter((x) => x.puan >= 0.5)
.sort((a, b) => b.puan - a.puan)[0]?.d;
if (!aday) return { durum: 'eksik', kategori: kat, belge: null, kalan: null };
const son = aday.sonKullanma ? new Date(aday.sonKullanma) : null;
const kalan = son && !Number.isNaN(son.getTime()) ? Math.floor((son - bugun) / 86400000) : null;
const durum = kalan != null && kalan < 0 ? 'suresi_dolmus' : kalan != null && kalan <= 30 ? 'yakinda' : 'hazir';
return { durum, kategori: kat, belge: aday, kalan };
}
const GORUNUM = {
hazir: ['Depoda hazır', 'bg-emerald-100 text-emerald-800'],
yakinda: ['Yakında dolacak', 'bg-amber-100 text-amber-800'],
suresi_dolmus: ['Süresi dolmuş', 'bg-red-100 text-red-800'],
eksik: ['Depoda yok', 'bg-slate-200 text-slate-700'],
};
export const YeterlilikRozeti = ({ belge }) => {
const [depo, setDepo] = useState(depoCache);
useEffect(() => { if (!depo) depoyuGetir().then(setDepo); }, [depo]);
if (!depo || !belge) return null;
const e = eslestir(belge, depo);
const [etiket, sinif] = GORUNUM[e.durum];
const ipucu = e.belge ? `${e.belge.ad}${e.kalan != null ? ` · ${e.kalan < 0 ? -e.kalan + ' gün önce doldu' : e.kalan + ' gün kaldı'}` : ''}` : e.kategori ? `Doküman Deposu'na "${e.kategori}" kategorisinde yükleyin` : 'Depoda eşleşen belge yok';
return <span title={ipucu} className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${sinif}`}>{etiket}</span>;
};
+3
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account';
import { Webhook, Key, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, ShieldCheck, Lock } from 'lucide-react';
import { McpRssKarti } from './McpRssKarti';
/**
* Gerçek API anahtarı yönetimi.
@@ -154,6 +155,8 @@ export const ApiWebhooks = () => {
)}
</div>
<McpRssKarti />
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<p className="text-[11px] text-slate-600 leading-relaxed">
<strong>Kullanım:</strong> İsteklerinize <code className="bg-white px-1 rounded border">Authorization: Bearer ih_live_…</code>
+48
View File
@@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import { CalendarClock, Eye, Loader2 } from 'lucide-react';
import { getToken } from '../../services/auth';
/**
* Haftalık brief tercihi (Pazartesi 08:00): bu hafta yapılacak takipli
* ihaleler + geçen hafta sonuçlanan takipliler — Telegram ve e-posta.
* "Önizle" şu anki içeriği gösterir; anahtar kapatır.
*/
const iste = (opts = {}) => fetch('/api/me/brief', { headers: { Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' }, ...opts }).then((r) => r.json());
export const BriefTercihi = () => {
const [kapali, setKapali] = useState(false);
const [metin, setMetin] = useState(null);
const [mesgul, setMesgul] = useState(false);
useEffect(() => { iste().then((d) => { if (d?.success) setKapali(!!d.kapali); }).catch(() => {}); }, []);
const degistir = async () => {
const yeni = !kapali; setKapali(yeni);
try { await iste({ method: 'POST', body: JSON.stringify({ kapali: yeni }) }); } catch { setKapali(!yeni); }
};
const onizle = async () => {
setMesgul(true);
try { const d = await iste(); setMetin(d?.metin || 'Önizleme alınamadı.'); } catch { setMetin('Önizleme alınamadı.'); } finally { setMesgul(false); }
};
return (
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2"><CalendarClock className="w-4 h-4 text-amber-500" /> Haftalık Brief</h3>
<p className="text-[11px] text-slate-500 mt-1">Her Pazartesi 08:00: bu hafta yapılacak takipli ihaleler ve geçen hafta sonuçlanan takipliler — Telegram (bağlıysa) ve e-posta ile tek mesaj.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={onizle} disabled={mesgul} className="text-[11px] flex items-center gap-1 border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">
{mesgul ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />} Önizle
</button>
<button role="switch" aria-checked={!kapali} onClick={degistir}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition ${kapali ? 'bg-slate-300' : 'bg-emerald-500'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${kapali ? 'translate-x-0.5' : 'translate-x-4'}`} />
</button>
</div>
</div>
{metin && <pre className="whitespace-pre-wrap rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 font-sans leading-relaxed">{metin}</pre>}
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
import React, { useState } from 'react';
import { Plug, Rss, Copy, Check } from 'lucide-react';
/** MCP sunucusu + RSS besleme kartı (API & Webhooks sekmesi). */
export const McpRssKarti = () => {
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://ihaleist.com';
const url = `${origin}/mcp`;
const anahtar = 'ih_live_ANAHTARINIZ';
const [kopya, setKopya] = useState(null);
const kopyala = async (k, t) => { try { await navigator.clipboard.writeText(t); setKopya(k); setTimeout(() => setKopya(null), 1500); } catch { /* yok */ } };
const ornekler = [
{ k: 'cc', ad: 'Claude Code', not: 'terminalde tek komut', kod: `claude mcp add --transport http ihaleist ${url} --header "Authorization: Bearer ${anahtar}"` },
{ k: 'cursor', ad: 'Cursor / Windsurf', not: '.cursor/mcp.json', kod: JSON.stringify({ mcpServers: { ihaleist: { url, headers: { Authorization: `Bearer ${anahtar}` } } } }, null, 2) },
{ k: 'desktop', ad: 'Claude Desktop', not: 'claude_desktop_config.json (mcp-remote)', kod: JSON.stringify({ mcpServers: { ihaleist: { command: 'npx', args: ['-y', 'mcp-remote', url, '--header', `Authorization: Bearer ${anahtar}`] } } }, null, 2) },
];
const rss = `${origin}/api/feeds/tenders.rss?key=${anahtar}&il=İstanbul&q=eldiven`;
return (
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-4">
<div className="border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2"><Plug className="w-4 h-4 text-violet-600" /> MCP Sunucusu — yapay zekâ araçlarınıza İhaleist'i bağlayın</h3>
<p className="text-[11px] text-slate-500 mt-1">Claude Code, Cursor, Claude Desktop gibi MCP destekli araçlar İhaleist'i doğrudan sorgular: <code>search_tenders</code>, <code>get_tender</code>, <code>search_results</code>, <code>search_items</code>, <code>upcoming_deadlines</code>. Yukarıdaki API anahtarınızı kullanır.</p>
</div>
{ornekler.map((o) => (
<div key={o.k} className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-slate-800">{o.ad} <span className="font-normal text-slate-500">— {o.not}</span></p>
<button onClick={() => kopyala(o.k, o.kod)} className="text-[11px] flex items-center gap-1 border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">{kopya === o.k ? <Check className="w-3 h-3 text-emerald-600" /> : <Copy className="w-3 h-3" />} Kopyala</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-slate-900 text-slate-100 p-2 text-[11px]">{o.kod}</pre>
</div>
))}
<div className="border-t border-slate-100 pt-3 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-slate-800 flex items-center gap-1"><Rss className="w-3.5 h-3.5 text-orange-500" /> RSS beslemesi <span className="font-normal text-slate-500">— Feedly, Outlook, Slack RSS, n8n… (il, sektor, q filtreleri)</span></p>
<button onClick={() => kopyala('rss', rss)} className="text-[11px] flex items-center gap-1 border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">{kopya === 'rss' ? <Check className="w-3 h-3 text-emerald-600" /> : <Copy className="w-3 h-3" />} Kopyala</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-slate-900 text-slate-100 p-2 text-[11px]">{rss}</pre>
<p className="text-[11px] text-slate-500">Sonuç ilanları beslemesi anahtarsız: <code>{origin}/api/feeds/sonuc.rss</code> (firma, idare, il filtreleri). Anahtarı <code>ih_live_ANAHTARINIZ</code> yerine yazın.</p>
</div>
</div>
);
};
+3 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { TelegramBaglanti } from './TelegramBaglanti';
import { BriefTercihi } from './BriefTercihi';
import { getToken } from '../../services/auth';
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
@@ -108,7 +109,8 @@ export const NotifySubscriptions = () => {
return (
<>
<TelegramBaglanti />
<BriefTercihi />
<TelegramBaglanti />
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
+6 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useApp } from '../../context/AppContext';
import { redFlags, marketConcentration, formatTL } from '../../services/analytics';
import { ShieldAlert, AlertTriangle, TrendingDown, Building2, Users, Info } from 'lucide-react';
import { ShieldAlert, AlertTriangle, TrendingDown, Building2, Users, Info, Clock, CalendarX, Hourglass, EyeOff, Scissors } from 'lucide-react';
const SEVIYE = {
yuksek: { etiket: 'Yüksek', kutu: 'bg-red-50 border-red-200', rozet: 'bg-red-100 text-red-800', cizgi: 'bg-red-500' },
@@ -15,6 +15,11 @@ const TUR_IKON = {
'Tahmin üstü bedel': AlertTriangle,
'Aşırı düşük teklif': TrendingDown,
'Tedarikçi yoğunlaşması': Building2,
'Kısa süre': Clock,
'Hafta sonu tarih': CalendarX,
'Geç sözleşme': Hourglass,
'Kazananı gizli': EyeOff,
'Parçalama şüphesi': Scissors,
};
export const RedFlags = () => {
+60
View File
@@ -204,6 +204,7 @@ export function redFlags(tenders) {
// Firma-kurum yoğunlaşması için sayaçlar
const firmKurum = new Map(); // "firma||kurum" -> sayı
const kurumToplam = new Map(); // kurum -> sonuçlanan ihale sayısı
const dtKurum = new Map(); // "kurum||sektor" -> [{t, d}] (doğrudan temin parçalama)
for (const t of tenders) {
const bedel = parseTL(t.sozlesmeBedeli);
@@ -247,6 +248,48 @@ export function redFlags(tenders) {
}
}
// 6) Kısa süre: ilan → ihale tarihi 7 günden az (açık ihalelerde)
const ilanT = parseDateTR(t.ilanTarihi);
const ihaleT = parseDateTR(t.tarih);
if (!t.kazananFirma && ilanT && ihaleT && ihaleT > ilanT && (ihaleT - ilanT) / 86400000 < 7) {
flags.push({
id: `sure-${t.id}`, seviye: 'orta', tur: 'Kısa süre', tender: t,
deger: `${Math.round((ihaleT - ilanT) / 86400000)} gün`,
aciklama: 'İlan ile ihale tarihi arasında 7 günden az var — hazırlık süresi yetersiz, katılım daralır.',
});
}
// 7) Hafta sonu ihale tarihi
if (!t.kazananFirma && ihaleT && (ihaleT.getDay() === 0 || ihaleT.getDay() === 6)) {
flags.push({
id: `hsonu-${t.id}`, seviye: 'dusuk', tur: 'Hafta sonu tarih', tender: t,
deger: ihaleT.getDay() === 6 ? 'Cumartesi' : 'Pazar',
aciklama: 'İhale tarihi hafta sonuna düşüyor; idare kapalıyken soru sorulamaz, teslim riski artar.',
});
}
// 8) Geç sözleşme: ihale tarihinden 180+ gün sonra sözleşme
const sozT = parseDateTR(t.sozlesmeTarihi);
if (sozT && ihaleT && (sozT - ihaleT) / 86400000 > 180) {
flags.push({
id: `gec-${t.id}`, seviye: 'orta', tur: 'Geç sözleşme', tender: t,
deger: `${Math.round((sozT - ihaleT) / 86400000)} gün`,
aciklama: 'Sözleşme ihaleden 180 günden uzun süre sonra imzalanmış — uzun değerlendirme, teklif geçerlilik ve fiyat riski.',
});
}
// 9) Kazananı açıklanmamış sonuç
if (t.durum === 'Sonuçlandı' && !t.kazananFirma && (bedel != null || t.teklifSayisi != null)) {
flags.push({
id: `gizli-${t.id}`, seviye: 'orta', tur: 'Kazananı gizli', tender: t,
aciklama: 'Sonuç ilanı var ama kazanan firma adı verilmemiş — şeffaflık eksiği.',
});
}
// 10) Parçalama şüphesi (sayaç): aynı idare, aynı sektör, 30 günde 3+ doğrudan temin
if (/doğrudan|dt\b/i.test(String(t.alimTuru || t.usul || '')) && t.kurum && ihaleT) {
const key = `${t.kurum.trim()}||${t.sektor || ''}`;
const arr = dtKurum.get(key) || [];
arr.push({ t, d: ihaleT });
dtKurum.set(key, arr);
}
if (t.kazananFirma && t.kurum) {
const key = `${t.kazananFirma.trim()}||${t.kurum.trim()}`;
firmKurum.set(key, (firmKurum.get(key) || 0) + 1);
@@ -267,6 +310,23 @@ export function redFlags(tenders) {
}
}
// 10) Parçalama: 30 günlük pencerede aynı idare + sektörde 3+ doğrudan temin
for (const [key, arr] of dtKurum) {
if (arr.length < 3) continue;
arr.sort((a, b) => a.d - b.d);
for (let i = 0; i + 2 < arr.length; i++) {
if ((arr[i + 2].d - arr[i].d) / 86400000 <= 30) {
const [kurum, sektor] = key.split('||');
flags.push({
id: `parca-${key}-${arr[i].t.id}`, seviye: 'yuksek', tur: 'Parçalama şüphesi', tender: arr[i].t, kurum,
deger: `${arr.length} DT / 30 gün`,
aciklama: `${kurum}, ${sektor || 'aynı'} alanında 30 gün içinde ${arr.length} doğrudan temin yapmış — ihaleye çıkmaktan kaçınma (parçalama) şüphesi, 4734 md. 5 ve 22/d sınırı.`,
});
break;
}
}
}
const siraDeger = { yuksek: 0, orta: 1, dusuk: 2 };
flags.sort((a, b) => siraDeger[a.seviye] - siraDeger[b.seviye]);
return {