180 lines
13 KiB
React
180 lines
13 KiB
React
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>
|
||
);
|
||
};
|