Files
ihaleist/src/components/TeklifAsistani.jsx
T
hikmettanandClaude Fable 5.1 48f0c48732 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>
2026-09-04 02:23:41 +03:00

144 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { 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>
);
};