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
+131
View File
@@ -0,0 +1,131 @@
// Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut
// analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde:
// 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt
// 2) teklifDenetle: kullanıcının taslağı × ihale gereklilikleri → karşılanıyor/eksik/belirsiz
// 3) teklifTaslak: kapak mektubu / teknik / ticari / tam dosya bölümleri
// Sayılar (fiyat, süre, adet) yalnızca verilen veriden; model rakam uydurmaz,
// bilinmeyene [DOLDURUN] koyar.
import { analyzeSpec } from './ai.mjs';
import { ilanMetni, documentText, browserAvailable } from './documents.mjs';
import * as kalemStore from './kalem-store.mjs';
const DURAK = new Set(['ve', 'veya', 'ile', 'için', 'bir', 'bu', 'ne', 'nasıl', 'kaç', 'kadar', 'mi', 'mı', 'mu', 'mü', 'var', 'yok', 'olan', 'olarak', 'gibi', 'ihale', 'ihalede', 'şartname', 'şartnamede', 'nedir', 'midir', 'hangi']);
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR').replace(/[^\p{L}\p{N}\s%]/gu, ' ').replace(/\s+/g, ' ').trim();
export function terimler(soru) {
return [...new Set(norm(soru).split(' ').filter((k) => k && !DURAK.has(k) && (k.length >= 3 || /^\d+$/.test(k))))].slice(0, 12);
}
/** Metni ~1200 karakterlik parçalara böler (paragraf sınırlarında). */
export function parcala(metin, hedef = 1200) {
const p = []; let tampon = '';
for (const satir of String(metin ?? '').replace(/\r/g, '').split('\n')) {
if (tampon.length + satir.length > hedef * 1.5 && tampon.trim()) { p.push(tampon.trim()); tampon = ''; }
tampon += satir + '\n';
if (tampon.length >= hedef && satir.trim() === '') { p.push(tampon.trim()); tampon = ''; }
}
if (tampon.trim().length >= 40) p.push(tampon.trim());
return p;
}
/** Terim kapsamasına göre en iyi k parça. */
export function getir(parcalar, soru, k = 8) {
const t = terimler(soru);
if (!t.length) return [];
const ifade = norm(soru);
return parcalar
.map((icerik, i) => {
const m = norm(icerik);
let hit = 0;
for (const x of t) if (m.includes(x)) hit += /^\d+$/.test(x) ? 1.5 : 1;
const puan = hit / t.length + (ifade.length >= 8 && m.includes(ifade) ? 0.5 : 0);
return { n: i + 1, icerik, puan };
})
.filter((x) => x.puan > 0)
.sort((a, b) => b.puan - a.puan)
.slice(0, k);
}
/** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */
export async function ihaleMetni(ikn, verilen) {
if (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' };
if (ikn && (await browserAvailable().catch(() => false))) {
try { const r = await documentText(ikn); if (r?.text && r.text.length > 200) return { text: r.text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ }
}
if (ikn) {
try { const r = await ilanMetni(ikn); const text = typeof r === 'string' ? r : r?.text; if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ }
}
return { text: '', kaynak: null };
}
export async function belgeyeSor({ ikn, soru, text, tender, chain }) {
const q = String(soru ?? '').trim().slice(0, 500);
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
const { text: metin, kaynak } = await ihaleMetni(ikn, text);
if (!metin) throw new Error('Bu ihale için doküman metni bulunamadı — PDF\'i "Yapay Zeka Asistanı"ndan yükleyip tekrar sorun.');
const parcalar = parcala(metin);
const kaynaklar = getir(parcalar, q, 8);
if (!kaynaklar.length) return { cevap: 'Belgede bu soruyla eşleşen bir bölüm bulunamadı; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak };
const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik.slice(0, 1400)}`).join('\n\n');
const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Belgede bu bilgi bulunamadı" de, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`;
const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, systemOverride: sistem });
const cevap = String(out?.result?.cevap ?? '').trim() || 'Yanıt üretilemedi.';
const kullanilan = new Set([...cevap.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])));
return { cevap, kaynaklar: (kullanilan.size ? kaynaklar.filter((k) => kullanilan.has(k.n)) : kaynaklar.slice(0, 3)).map((k) => ({ n: k.n, alinti: k.icerik.slice(0, 1400) })), kaynak, model: out.model?.label };
}
function gereklilikMetni(tender, gereklilikler, metin) {
const g = [];
for (const x of Array.isArray(gereklilikler) ? gereklilikler : []) if (x) g.push(typeof x === 'string' ? x : x.belge || x.item || x.ad || JSON.stringify(x));
for (const y of Array.isArray(tender?.yeterlikKriterleri) ? tender.yeterlikKriterleri : []) if (y) g.push(String(y));
for (const i of Array.isArray(tender?.items) ? tender.items.slice(0, 30) : []) g.push(`Kalem: ${i.ad} ${i.miktar ?? ''} ${i.birim ?? ''}`.trim());
if (tender?.ikn && kalemStore.available()) for (const k of (kalemStore.kalemOku(tender.ikn) || []).slice(0, 30)) g.push(`Kalem: ${k.ad} ${k.miktar ?? ''} ${k.birim ?? ''}`.trim());
if (metin) for (const s of ['teminat', 'teslim süresi', 'geçerlilik', 'ceza', 'numune', 'belge']) { const p = getir(parcala(metin), s, 1)[0]; if (p) g.push(`${s}: ${p.icerik.slice(0, 300).replace(/\s+/g, ' ')}`); }
return [...new Set(g)].slice(0, 60);
}
export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) {
const t = String(taslak ?? '').trim().slice(0, 60000);
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.');
const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' }));
const liste = gereklilikMetni(tender, gereklilikler, metin);
if (!liste.length) throw new Error('Gereklilik listesi yok — önce derin analiz çalıştırın veya dokümanı yükleyin.');
const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür.`;
const out = await analyzeSpec({ text: `GEREKLİLİKLER:\n${liste.map((g, i) => `${i + 1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI:\n${t}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' });
const ham = Array.isArray(out?.result?.maddeler) ? out.result.maddeler : [];
const DUR = new Set(['karsilaniyor', 'eksik', 'belirsiz']);
const maddeler = liste.map((g, i) => {
const m = ham[i] ?? {};
return { gereklilik: g, durum: DUR.has(m.durum) ? m.durum : 'belirsiz', kanit: m.kanit ? String(m.kanit).slice(0, 400) : null, oneri: m.oneri ? String(m.oneri).slice(0, 400) : null };
});
const ok = maddeler.filter((m) => m.durum === 'karsilaniyor').length;
const yarim = maddeler.filter((m) => m.durum === 'belirsiz').length;
return { ozet: String(out?.result?.ozet ?? '').slice(0, 800), maddeler, uyum_yuzde: Math.round(((ok + yarim * 0.5) / maddeler.length) * 100), eksikler: maddeler.filter((m) => m.durum === 'eksik').map((m) => m.gereklilik), model: out.model?.label };
}
const BOLUMLER = {
kapak: ['Teklif Mektubu (Kapak)'],
teknik: ['Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ekler'],
ticari: ['Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
};
export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) {
const istenen = BOLUMLER[tur] || BOLUMLER.tam;
const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' }));
const liste = gereklilikMetni(tender, gereklilikler, metin);
const baglam = [
`İHALE: ${tender?.baslik ?? '?'} · İdare: ${tender?.kurum ?? '?'} · İl: ${tender?.sehir ?? '?'} · Tarih: ${tender?.tarih ?? '?'} · Usul: ${tender?.usul ?? tender?.alimTuru ?? '?'}${tender?.yaklasikMaliyet ? ' · Yaklaşık maliyet: ' + tender.yaklasikMaliyet : ''}${ikn ? ' · İKN ' + ikn : ''}`,
firma ? `FİRMA: ${typeof firma === 'string' ? firma : JSON.stringify(firma).slice(0, 1500)}` : null,
liste.length ? 'GEREKLİLİKLER / KALEMLER:\n' + liste.map((g) => '- ' + g).join('\n') : null,
Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.slice(0, 40).map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).join('\n') : null,
fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null,
].filter(Boolean).join('\n\n');
const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver.`;
const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' });
const bolumler = (Array.isArray(out?.result?.bolumler) ? out.result.bolumler : [])
.map((b) => ({ baslik: String(b?.baslik ?? '').slice(0, 200), icerik: String(b?.icerik ?? '').slice(0, 12000) }))
.filter((b) => b.baslik && b.icerik);
if (!bolumler.length) throw new Error('Model bölüm üretmedi; tekrar deneyin.');
return { bolumler, model: out.model?.label };
}
+107
View File
@@ -0,0 +1,107 @@
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı.
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as store from './user-store.mjs';
import { resultsByIkn } from './history-store.mjs';
import { emailConfigured, sendEmail } from './email.mjs';
import { sendTelegram, telegramConfigured } from './telegram.mjs';
import { yaklasanIhaleler, trTarih } from './feeds-mcp.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DURUM_DOSYASI = path.join(__dirname, 'data', 'brief-son.json');
function sonGonderim() {
try { return JSON.parse(fs.readFileSync(DURUM_DOSYASI, 'utf8')); } catch { return {}; }
}
function sonGonderimYaz(d) {
try { fs.mkdirSync(path.dirname(DURUM_DOSYASI), { recursive: true }); fs.writeFileSync(DURUM_DOSYASI, JSON.stringify(d)); } catch { /* yok */ }
}
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
/** Bir kullanıcı için brief içeriği. */
export function briefOlustur(email, tenders) {
const takip = (store.getData(email, 'takip') || []).map(String);
const yaklasan = yaklasanIhaleler(email, tenders, 7);
const birHafta = Date.now() - 7 * 86400000;
const sonuclar = [];
for (const ikn of takip.slice(0, 200)) {
const r = resultsByIkn(ikn) || [];
for (const s of r) {
const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih);
if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi });
}
}
const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' });
return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length };
}
export function briefMetni(b) {
const s = [`📋 Haftalık İhale Brief — ${b.hafta}`];
if (b.yaklasan.length) {
s.push('', `⏰ Bu hafta yapılacak takipli ihaleler (${b.yaklasan.length}):`);
for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih}${t.saat ? ' ' + t.saat : ''})`);
}
if (b.sonuclar.length) {
s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`);
for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`);
}
s.push('', 'ihaleist.com');
return s.join('\n');
}
export function briefHtml(b) {
const esc = (x) => String(x ?? '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const bolum = (baslik, satirlar) => satirlar.length
? `<h3 style="margin:18px 0 6px;font-size:15px;color:#0f172a">${baslik}</h3><ul style="margin:0;padding-left:18px;color:#334155;font-size:14px">${satirlar.map((x) => `<li style="margin:4px 0">${x}</li>`).join('')}</ul>` : '';
return `<div style="font-family:Inter,Arial,sans-serif;max-width:640px;margin:auto;padding:20px;color:#0f172a">
<h2 style="margin:0 0 4px;font-size:20px">📋 Haftalık İhale Brief</h2><div style="color:#64748b;font-size:13px">${esc(b.hafta)}</div>
${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `<b>${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'}</b> — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)})`))}
${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
}
export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) {
const tenders = await tenderGetir();
const r = { kullanici: 0, gonderilen: 0, bos: 0, hata: 0 };
for (const u of store.listUsers()) {
r.kullanici++;
try {
const tercih = store.getData(u.email, 'brief');
if (tercih && tercih.kapali && !zorla) continue;
const b = briefOlustur(u.email, tenders);
if (b.bos) { r.bos++; continue; }
const tg = store.getData(u.email, 'telegram');
let gitti = false;
if (telegramConfigured() && tg?.chatId) gitti = await sendTelegram(tg.chatId, briefMetni(b).slice(0, 3900)).then(() => true).catch(() => false);
if (emailConfigured()) gitti = (await sendEmail({ to: u.email, subject: `İhaleist — Haftalık Brief (${b.hafta})`, html: briefHtml(b), text: briefMetni(b) }).then(() => true).catch(() => false)) || gitti;
if (gitti) r.gonderilen++;
} catch (e) {
r.hata++;
console.warn('[brief]', u.email, e.message);
}
}
return r;
}
/** Saatlik kontrol: Pazartesi ≥08:00 ve bu hafta gönderilmediyse gönder. */
export function briefZamanlayici(tenderGetir) {
if (process.env.BRIEF_ENABLED === '0') { console.log('[brief] haftalık brief devre dışı (BRIEF_ENABLED=0)'); return; }
const kontrol = async () => {
const simdi = new Date();
if (simdi.getDay() !== 1 || simdi.getHours() < 8) return;
const anahtar = `${simdi.getFullYear()}-${simdi.getMonth()}-${simdi.getDate()}`;
const d = sonGonderim();
if (d.son === anahtar) return;
sonGonderimYaz({ son: anahtar });
const r = await haftalikBriefGonder(tenderGetir).catch((e) => ({ hata: e.message }));
console.log('[brief] haftalık brief:', JSON.stringify(r));
};
setTimeout(kontrol, 3 * 60 * 1000).unref?.();
setInterval(kontrol, 60 * 60 * 1000).unref?.();
console.log('[brief] haftalık brief zamanlandı (Pazartesi 08:00, saatlik kontrol)');
}
+196
View File
@@ -0,0 +1,196 @@
// RSS beslemeleri + MCP sunucusu (İhaleAB'den taşındı, ihaleist verisine uyarlandı).
//
// RSS: /api/feeds/tenders.rss?key=ih_live_…&q=&il=&sektor= (okuyucular başlık
// gönderemez → anahtar sorguda), /api/feeds/sonuc.rss (açık; EKAP sonuçları).
// MCP: POST /mcp — JSON-RPC 2.0 Streamable HTTP; Claude Code / Cursor /
// Claude Desktop (mcp-remote) araçları: search_tenders, get_tender,
// search_results, search_items, upcoming_deadlines. Bağımlılık yok.
import * as store from './user-store.mjs';
import { readHistory, resultsByIkn } from './history-store.mjs';
import * as kalemStore from './kalem-store.mjs';
const SITE = 'https://ihaleist.com';
const xml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR');
export function rssUret({ title, link, description, items }) {
const tarih = (v) => (v ? new Date(v).toUTCString() : new Date().toUTCString());
const ogeler = items.map((i) =>
`<item><title>${xml(i.title)}</title><link>${xml(i.link)}</link><guid isPermaLink="false">${xml(i.guid ?? i.link)}</guid>` +
(i.description ? `<description>${xml(i.description)}</description>` : '') +
(i.category ?? []).map((c) => `<category>${xml(c)}</category>`).join('') +
`<pubDate>${tarih(i.pubDate)}</pubDate></item>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>${xml(title)}</title><link>${xml(link)}</link><description>${xml(description)}</description><language>tr</language><lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${ogeler}</channel></rss>`;
}
/** dd.mm.yyyy → Date (ihaleist tarih biçimi). */
export function trTarih(s) {
const m = /(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(String(s ?? ''));
return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
}
/** Liste filtresi — RSS ve MCP ortak. */
export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}) {
const qq = norm(q); const ill = norm(il); const sek = norm(sektor);
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
return tenders
.filter((t) => !sadeceAcik || !t.kazananFirma)
.filter((t) => !sadeceAcik || !trTarih(t.tarih) || trTarih(t.tarih) >= bugun)
.filter((t) => !ill || norm(t.sehir).includes(ill))
.filter((t) => !sek || norm(t.sektor).includes(sek))
.filter((t) => !qq || norm(t.baslik).includes(qq) || norm(t.kurum).includes(qq) || String(t.ikn ?? '').includes(qq) ||
(Array.isArray(t.kalemler) && t.kalemler.some((k) => norm(k.ad ?? k).includes(qq))))
.slice(0, Math.min(Math.max(Number(limit) || 50, 1), 200));
}
export function ihaleRss(tenders, filtre) {
const liste = ihaleFiltrele(tenders, filtre);
const f = [filtre.il && `il ${filtre.il}`, filtre.sektor && filtre.sektor, filtre.q && `"${filtre.q}"`].filter(Boolean).join(', ');
return rssUret({
title: `İhaleist — ihaleler${f ? ' (' + f + ')' : ''}`,
link: SITE,
description: 'Türkiye kamu ihaleleri (ilan.gov.tr + EKAP). Filtreler URL\'de.',
items: liste.map((t) => ({
title: `${t.sehir ? t.sehir + ' · ' : ''}${t.baslik}`,
link: t.kaynakUrl || `${SITE}/?ikn=${encodeURIComponent(t.ikn || '')}`,
guid: t.id,
description: [t.kurum, t.tarih ? 'ihale ' + t.tarih + (t.saat ? ' ' + t.saat : '') : null, t.alimTuru, t.ikn ? 'İKN ' + t.ikn : null].filter(Boolean).join(' · '),
pubDate: t.yayinZamani || trTarih(t.ilanTarihi),
category: [t.sektor, t.alimTuru].filter(Boolean),
})),
});
}
export function sonucRss({ firma, idare, il } = {}) {
const { tenders } = readHistory(50, { firma, idare, il });
return rssUret({
title: 'İhaleist — EKAP sonuç ilanları',
link: SITE,
description: 'Sonuçlanan ihaleler: kazanan firma, sözleşme bedeli, teklif sayısı.',
items: tenders.map((t) => ({
title: `${t.kazananFirma ?? 'kazanan açıklanmadı'} ← ${t.baslik}`,
link: `${SITE}/?ikn=${encodeURIComponent(t.ikn || '')}`,
guid: t.id,
description: [t.kurum, t.sozlesmeBedeli != null ? `${t.sozlesmeBedeli} ${t.paraBirimi ?? 'TL'}` : null, t.teklifSayisi != null ? `${t.teklifSayisi} teklif` : null, t.sozlesmeTarihi ? 'sözleşme ' + t.sozlesmeTarihi : null].filter(Boolean).join(' · '),
pubDate: trTarih(t.sozlesmeTarihi) || trTarih(t.tarih),
category: [t.sektor].filter(Boolean),
})),
});
}
// ---- MCP -----------------------------------------------------------------
export const PROTOKOL = '2025-03-26';
export const SUNUCU = { name: 'ihaleist', version: '1.0.0' };
export const TOOLS = [
{ name: 'search_tenders', description: 'Türkiye kamu ihalelerinde arama (ilan.gov.tr + EKAP + doğrudan temin). q başlık/kurum/İKN/teklif cetveli kaleminde arar.',
inputSchema: { type: 'object', properties: { q: { type: 'string' }, il: { type: 'string', description: 'İl adı' }, sektor: { type: 'string' }, sadece_acik: { type: 'boolean', description: 'Varsayılan true' }, limit: { type: 'integer', minimum: 1, maximum: 100 } } } },
{ name: 'get_tender', description: 'İKN ile ihale ayrıntısı; varsa teklif cetveli kalemleri ve sonuç ilanı.',
inputSchema: { type: 'object', properties: { ikn: { type: 'string' } }, required: ['ikn'] } },
{ name: 'search_results', description: 'EKAP sonuç ilanlarında kazanan firma / idare / il ile arama: sözleşme bedeli, teklif sayısı, kırım.',
inputSchema: { type: 'object', properties: { firma: { type: 'string' }, idare: { type: 'string' }, il: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
{ name: 'search_items', description: 'Teklif cetveli kalemlerinde (OKAS/ürün adı) arama — hangi ihalede hangi ürün istenmiş.',
inputSchema: { type: 'object', properties: { q: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 400 } }, required: ['q'] } },
{ name: 'upcoming_deadlines', description: 'API anahtarı sahibinin takip ettiği ihalelerden N gün içinde yapılacak olanlar.',
inputSchema: { type: 'object', properties: { gun: { type: 'integer', minimum: 1, maximum: 90 } } } },
];
const hata = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
const sonuc = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
export function yaklasanIhaleler(email, tenders, gun = 14) {
const takip = new Set((store.getData(email, 'takip') || []).map(String));
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
const sinir = new Date(bugun.getTime() + gun * 86400000);
return tenders
.filter((t) => t.ikn && takip.has(String(t.ikn)))
.map((t) => ({ t, d: trTarih(t.tarih) }))
.filter(({ d }) => d && d >= bugun && d <= sinir)
.sort((a, b) => a.d - b.d)
.map(({ t, d }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir, tarih: t.tarih, saat: t.saat, kalan_gun: Math.round((d - bugun) / 86400000), kaynakUrl: t.kaynakUrl }));
}
export async function aracCalistir(name, args, user, ctx) {
const tenders = await ctx.tenderGetir();
switch (name) {
case 'search_tenders': {
const liste = ihaleFiltrele(tenders, { q: args?.q, il: args?.il, sektor: args?.sektor, sadeceAcik: args?.sadece_acik !== false, limit: args?.limit ?? 20 });
return { count: liste.length, tenders: liste.map(({ items, aciklama, ...t }) => t) };
}
case 'get_tender': {
const ikn = String(args?.ikn ?? '').trim();
const t = tenders.find((x) => String(x.ikn) === ikn);
const kalemler = kalemStore.available() ? kalemStore.kalemOku(ikn) : null;
const sonuc = resultsByIkn(ikn);
if (!t && !sonuc?.length) throw new Error('İhale bulunamadı: ' + ikn);
return { tender: t ?? null, kalemler: kalemler ?? [], sonuc: sonuc ?? [] };
}
case 'search_results': {
const r = readHistory(Math.min(Number(args?.limit ?? 50) || 50, 200), { firma: args?.firma, idare: args?.idare, il: args?.il });
return { total: r.totalCount, results: r.tenders.map(({ items, ...t }) => t) };
}
case 'search_items':
return { items: kalemStore.available() ? kalemStore.kalemAra(String(args?.q ?? ''), { limit: Math.min(Number(args?.limit ?? 100) || 100, 400) }) : [] };
case 'upcoming_deadlines':
return { tenders: yaklasanIhaleler(user.email, tenders, Number(args?.gun ?? 14) || 14) };
default:
throw new Error('Bilinmeyen araç: ' + name);
}
}
export async function rpcIsle(req, user, ctx) {
const { id, method, params } = req ?? {};
if (req?.jsonrpc !== '2.0' || typeof method !== 'string') return hata(id, -32600, 'Geçersiz istek');
const bildirim = id === undefined;
switch (method) {
case 'initialize':
return sonuc(id, { protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOKOL, capabilities: { tools: { listChanged: false } }, serverInfo: SUNUCU,
instructions: 'İhaleist: Türkiye kamu ihaleleri. search_tenders → get_tender(ikn) → search_results(firma) tipik akıştır; Türkçe sorgu kullanın.' });
case 'notifications/initialized':
case 'notifications/cancelled':
return null;
case 'ping':
return sonuc(id, {});
case 'tools/list':
return sonuc(id, { tools: TOOLS });
case 'tools/call': {
const name = params?.name;
if (!TOOLS.some((t) => t.name === name)) return hata(id, -32602, 'Bilinmeyen araç: ' + name);
try {
const r = await aracCalistir(name, params?.arguments ?? {}, user, ctx);
return sonuc(id, { content: [{ type: 'text', text: JSON.stringify(r, null, 1) }], isError: false });
} catch (e) {
return sonuc(id, { content: [{ type: 'text', text: e.message }], isError: true });
}
}
default:
return bildirim ? null : hata(id, -32601, 'Yöntem bulunamadı: ' + method);
}
}
export function anahtarAyikla(authorization, xApiKey, sorguKey) {
const bearer = /^Bearer\s+(\S+)$/i.exec(authorization || '')?.[1];
const aday = (bearer || xApiKey || sorguKey || '').trim();
return aday.startsWith('ih_live_') ? aday : null;
}
/** HTTP giriş noktası — index.js'ten çağrılır. */
export async function mcpIstek(req, res, { readBody, json, tenderGetir }) {
if (req.method === 'GET') return json(res, 405, hata(null, -32000, 'SSE akışı desteklenmiyor; POST kullanın'));
if (req.method === 'DELETE') { res.writeHead(200); return res.end(); }
const key = anahtarAyikla(req.headers['authorization'], req.headers['x-api-key'], null);
const user = key ? store.verifyApiKey(key) : null;
if (!user) return json(res, 401, hata(null, -32001, 'Kimlik doğrulanamadı: Authorization: Bearer ih_live_… gerekli (API & Webhooks)'));
let govde;
try { govde = await readBody(req, 512 * 1024); } catch { return json(res, 400, hata(null, -32700, 'JSON ayrıştırılamadı')); }
const ctx = { tenderGetir };
if (Array.isArray(govde)) {
const out = (await Promise.all(govde.map((r) => rpcIsle(r, user, ctx)))).filter(Boolean);
if (!out.length) { res.writeHead(202); return res.end(); }
return json(res, 200, out);
}
const r = await rpcIsle(govde, user, ctx);
if (!r) { res.writeHead(202); return res.end(); }
return json(res, 200, r);
}
+64
View File
@@ -43,6 +43,9 @@ import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
import { startScheduler, schedulerStatus } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs';
import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs';
import { belgeyeSor, teklifDenetle, teklifTaslak } from './ai-teklif.mjs';
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs';
@@ -1321,6 +1324,67 @@ const server = http.createServer(async (req, res) => {
}
}
// ---- RSS beslemeleri + MCP sunucusu + haftalık brief + teklif asistanı (İhaleAB'den taşındı) ----
if (url.pathname === '/api/feeds/tenders.rss') {
const key = url.searchParams.get('key') || '';
const sahip = key.startsWith('ih_live_') ? store.verifyApiKey(key) : null;
if (!sahip) { res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('API anahtarı gerekli: ?key=ih_live_… (API & Webhooks)'); }
const out = await tendersPayload();
const xml = ihaleRss(out.tenders, { q: url.searchParams.get('q'), il: url.searchParams.get('il'), sektor: url.searchParams.get('sektor'), sadeceAcik: url.searchParams.get('acik') !== '0', limit: url.searchParams.get('limit') || 50 });
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=600' });
return res.end(xml);
}
if (url.pathname === '/api/feeds/sonuc.rss') {
const xml = sonucRss({ firma: url.searchParams.get('firma'), idare: url.searchParams.get('idare'), il: url.searchParams.get('il') });
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' });
return res.end(xml);
}
if (url.pathname === '/mcp') {
return mcpIstek(req, res, { readBody, json, tenderGetir: async () => (await tendersPayload()).tenders });
}
if (url.pathname === '/api/me/yaklasan') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const out = await tendersPayload();
return json(res, 200, { success: true, tenders: yaklasanIhaleler(user.email, out.tenders, Number(url.searchParams.get('gun')) || 14) });
}
if (url.pathname === '/api/me/brief') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
if (req.method === 'POST') {
const body = await readBody(req).catch(() => ({}));
store.setData(user.email, 'brief', { kapali: body.kapali === true });
return json(res, 200, { success: true, kapali: body.kapali === true });
}
const out = await tendersPayload();
const b = briefOlustur(user.email, out.tenders);
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) });
}
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders, { zorla: url.searchParams.get('zorla') === '1' });
return json(res, 200, { success: true, ...r });
}
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI yapılandırılmamış — sunucuya bir sağlayıcı anahtarı ekleyin.' });
try {
const body = await readBody(req, 2 * 1024 * 1024);
const ortak = { ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text };
const out = url.pathname === '/api/ai/belge-sor'
? await belgeyeSor({ ...ortak, soru: body.soru })
: url.pathname === '/api/ai/teklif-denetim'
? await teklifDenetle({ ...ortak, taslak: body.taslak, gereklilikler: body.gereklilikler })
: await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler });
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, e.attempts ? 502 : 400, { success: false, error: e.message });
}
}
if (url.pathname === '/api/tenders') {
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
return json(res, 200, out);
+4
View File
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url';
import { historyStats } from './history-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { zeyilnameTara, sonucTara } from './zeyilname.mjs';
import { briefZamanlayici } from './brief.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -190,6 +191,9 @@ export function startScheduler({ tenderGetir } = {}) {
setTimeout(takipTuru, 8 * 60 * 1000).unref?.();
setInterval(takipTuru, Math.max(1, tSaat) * 3600 * 1000).unref?.();
}
// Haftalık brief (Pazartesi 08:00): takipli ihalelerin tarihleri + sonuçlar
if (tenderGetir) briefZamanlayici(tenderGetir);
// Otomatik e-posta bildirimi
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
+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 {