Files
ihaleist/src/components/TeklifAsistani.jsx
T

180 lines
13 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, useRef, useEffect } from 'react';
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
import { getToken } from '../services/auth';
import { listDocuments } from '../services/account';
import { useApp } from '../context/AppContext';
import { requestOfferAssistant } from '../services/offerAssistant';
/**
* 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 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 {authUser} = useApp();
const token = getToken();
const identity = useRef({token, revision:0});
if (identity.current.token !== token) identity.current = {token, revision:identity.current.revision + 1};
return <OfferAssistantSession key={`${authUser?.email || ''}:${tender.ikn || tender.id}:${identity.current.revision}`} tender={tender} />;
};
const OfferAssistantSession = ({ tender }) => {
const live = useRef(true);
const session = useRef(getToken());
const busy = useRef(false);
useEffect(() => { live.current = true; return () => { live.current = false; }; }, []);
const current = () => live.current && Boolean(session.current) && getToken() === session.current;
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents});
const [arac, setArac] = useState('sor');
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
const [sourceName, setSourceName] = useState('');
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 [draftReview, setDraftReview] = useState(null);
const [draftSource, setDraftSource] = useState(null);
const [mesgul, setMesgul] = useState(false);
const [hata, setHata] = useState(null);
const calistir = async (fn) => {
if (busy.current || !current()) return;
busy.current = true; setMesgul(true); setHata(null);
try { await fn(); }
catch (e) { if (current()) setHata(e.message); }
finally { busy.current = false; if (current()) setMesgul(false); }
};
const sor = (q) => calistir(async () => {
const result = await post('/api/ai/belge-sor', {ikn:tender.ikn, soru:q, text:metin || undefined, tender});
if (current()) setCevap(result);
});
const denetle = () => calistir(async () => {
const result = await post('/api/ai/teklif-denetim', {ikn:tender.ikn, taslak, tender, text:metin || undefined});
if (current()) setDenetim(result);
});
const uret = () => calistir(async () => {
const result = await post('/api/ai/teklif-taslak', {ikn:tender.ikn, tur, tender, text:metin || undefined}, listDocuments);
if (current()) { setBolumler(result.bolumler || []); setDraftReview(result.draftReview || null); setDraftSource(metin); }
});
const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); };
const dosyaOku = (f) => {
if (!f) return;
return calistir(async () => {
const { extractText } = await import('../services/pdfText');
if (!current()) return;
const r = await extractText(f);
if (typeof r.text !== 'string' || !r.text.trim()) throw new Error('Dosyada okunabilir metin bulunamadı. Önceki kaynak değiştirilmedi. Başka bir dosya seçin.');
if (current()) { setMetin(r.text); setSourceName(f.name); setCevap(null); setDenetim(null); }
});
};
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 ? `Kaynak: ${sourceName} (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'}
<input type="file" disabled={mesgul} accept=".pdf,.txt,.md" className="hidden" onChange={(e) => {const file=e.target.files?.[0];e.target.value='';dosyaOku(file);}} />
</label>
</div>
{metin && <button type="button" disabled={mesgul} className="text-xs text-blue-700 underline" onClick={() => {setMetin('');setSourceName('');setCevap(null);setDenetim(null);}}>Yüklenen kaynağı kaldır ve ilan kaynağını kullan</button>}
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtların kaynak parçalarını kontrol edin; AI yanıtları hata veya eksik bilgi içerebilir.</p>
{hata && <p role="alert" 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" disabled={mesgul} 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.kapsam && <p className="text-xs text-amber-800">{cevap.kapsam} {cevap.sourceReview && `${cevap.sourceReview.totalChunks} parçadan ${cevap.sourceReview.selectedChunks} tanesi modele sunuldu.`}</p>}
{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 || denetim.belirsizSayisi ? 'text-amber-700' : 'text-emerald-700'}`}>%{denetim.uyum_yuzde}</span>
<span className="text-xs text-slate-500">{denetim.maddeler.length} gereklilik · {denetim.eksikler.length} eksik · {denetim.belirsizSayisi ?? 0} kontrol gerekli</span>
</div>
<p className="text-xs text-slate-600">Listede alıntıyla desteklenen karşılanma oranı. {denetim.kapsam}</p>
{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.length > 0 && draftSource !== metin && <p role="alert" className="text-xs text-amber-800">Kaynak değişti. Aşağıdaki taslak önceki kaynakla üretildi; düzenlemeleriniz korundu. Yeni kaynakla karşılaştırın veya yeniden üretin.</p>}
{draftReview && <div role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p className="font-semibold">{draftReview.status === 'partial' ? 'Kısmi taslak — bölüm kontrolü gerekli' : 'Taslak — kullanıcı kontrolü gerekli'}</p>
<p>{draftReview.note}</p>
{draftReview.missingSections?.length > 0 && <p>Modelin tamamlamadığı bölümler: {draftReview.missingSections.join(', ')}. Aşağıdaki DOLDURUN alanlarını düzenleyin.</p>}
{draftReview.extraSections?.length > 0 && <p>İstenen başlıklara eşleşmeyen ek bölümler: {draftReview.extraSections.join(', ')}.</p>}
</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>
);
};