516 lines
25 KiB
React
516 lines
25 KiB
React
import { DocumentTextReview } from './DocumentTextReview';
|
||
import { readableDocument, inspectStoredDocument, retryDocumentExtraction } from '../services/storedDocument';
|
||
import { documentProcessingLabel } from '../services/documentProcessing';
|
||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useApp } from '../context/AppContext';
|
||
import { getToken } from '../services/auth';
|
||
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip, Route } from 'lucide-react';
|
||
import { uploadDocument, listTenderDocuments, deleteDocument, reportDocumentReadError } from '../services/account';
|
||
import { extractText } from '../services/pdfText';
|
||
|
||
/**
|
||
* İhale dokümanları paneli.
|
||
* - EKAP doküman bağlantısını üretip kullanıcının tarayıcısında açar
|
||
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir
|
||
*/
|
||
export const TenderDocuments = ({tender}) => {
|
||
const {authUser} = useApp();
|
||
return <TenderDocumentSession key={`${authUser?.email || ''}:${tender.ikn || tender.id}`} tender={tender} />;
|
||
};
|
||
const TenderDocumentSession = ({ tender }) => {
|
||
const { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
|
||
const readLifetime = useRef(new AbortController());
|
||
const sessionToken = useRef(getToken());
|
||
const current = () => !readLifetime.current.signal.aborted && Boolean(sessionToken.current) && sessionToken.current === getToken();
|
||
const assertCurrent = () => {
|
||
readLifetime.current.signal.throwIfAborted();
|
||
if (!current()) throw new Error('Oturum değişti; belgeyi yeniden açın.');
|
||
};
|
||
useEffect(() => {
|
||
readLifetime.current = new AbortController();
|
||
return () => readLifetime.current.abort();
|
||
}, []);
|
||
const useOwnedState = initial => {
|
||
const [value,setValue] = useState(initial);
|
||
const setOwned = useCallback(next => {if(current())setValue(next);},[]);
|
||
return [value,setOwned];
|
||
};
|
||
const [busy, setBusy] = useOwnedState(null);
|
||
const [error, setError] = useOwnedState(null);
|
||
const [bilgi, setBilgi] = useOwnedState(null);
|
||
const [ilerleme, setIlerleme] = useOwnedState('');
|
||
const [metinOnizleme, setMetinOnizleme] = useOwnedState(null);
|
||
// Son üretilen EKAP doküman adresi — yeni sekme engellenirse kullanıcı
|
||
// gerçek bir bağlantıya tıklayarak açabilsin (bağlantı tıklaması engellenmez).
|
||
const [ekapUrl, setEkapUrl] = useOwnedState(null);
|
||
|
||
// Eski kayıtlarda İKN boşluklu gelebiliyor (" 2026/1622878"); EKAP tespiti
|
||
// ve sunucu çağrıları boşluksuz değerle yapılır.
|
||
const ikn = String(tender?.ikn || '').replace(/[\s\u00a0]+/g, '') || undefined;
|
||
const [belgeler, setBelgeler] = useOwnedState([]);
|
||
const [yukleniyor, setYukleniyor] = useOwnedState(false);
|
||
const [listeHata, setListeHata] = useOwnedState(null);
|
||
const [listeBekliyor, setListeBekliyor] = useOwnedState(false);
|
||
const listeIstegi = useRef(0);
|
||
const onizlemeIstegi = useRef(0);
|
||
const dosyaRef = useRef(null);
|
||
|
||
const belgeleriYukle = useCallback(async () => {
|
||
if (!ikn || !current()) return;
|
||
const request = ++listeIstegi.current;
|
||
setListeBekliyor(true); setListeHata(null);
|
||
try {
|
||
const rows = await listTenderDocuments(ikn);
|
||
if (request === listeIstegi.current) setBelgeler(rows);
|
||
} catch (e) {
|
||
if (request === listeIstegi.current) setListeHata(e.message);
|
||
} finally {
|
||
if (request === listeIstegi.current) setListeBekliyor(false);
|
||
}
|
||
}, [ikn]);
|
||
useEffect(() => { setBelgeler([]); setMetinOnizleme(null); belgeleriYukle(); return () => { listeIstegi.current++; onizlemeIstegi.current++; }; }, [belgeleriYukle]);
|
||
|
||
// EKAP dokümanı kullanıcının tarayıcısına iniyor; indirdiği dosyayı buraya
|
||
// yükleyince ihaleye bağlanır ve metni AI analizine verilebilir.
|
||
const dosyaSec = async (e) => {
|
||
const f = e.target.files?.[0];
|
||
e.target.value = '';
|
||
if (!f) return;
|
||
if (f.size > 10 * 1024 * 1024) {
|
||
setError(`Yüklenemedi: ${f.name} ${(f.size / 1048576).toFixed(1)} MB — sınır 10 MB.`);
|
||
return;
|
||
}
|
||
setYukleniyor(true); setError(null);
|
||
try {
|
||
// Sunucu docx/xlsx/zip için teklif cetveli kalemlerini yüklemede
|
||
// otomatik çıkarır. PDF tarayıcıda çözüldüğü için metni biz göndeririz.
|
||
assertCurrent();
|
||
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, {documentType:'ihale'});
|
||
assertCurrent();
|
||
let kalem = cevap?.kalem?.sayi || null;
|
||
let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null;
|
||
if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) {
|
||
try {
|
||
let text;
|
||
try { ({text} = await extractText(f,{signal:readLifetime.current.signal})); }
|
||
catch (readError) {
|
||
assertCurrent();
|
||
if(readError.name === 'AbortError')throw readError;
|
||
try { await reportDocumentReadError(cevap.id,cevap.belgeler.find(b=>b.id===cevap.id)?.isleme); }
|
||
catch { throw new Error(`${readError.message} (hata durumu kaydedilemedi; listeyi yenileyin)`); }
|
||
throw readError;
|
||
}
|
||
const { kalemCikar } = await import('../services/kalem');
|
||
assertCurrent();
|
||
const d = await kalemCikar({ ikn, metin: text, dosya: f.name, documentId:cevap.id });
|
||
kalem = d.sayi || null;
|
||
} catch (error) { kalemHata = error.message; }
|
||
}
|
||
belgeleriYukle();
|
||
setBilgi({ yuklendi: f.name, kalem, kalemHata });
|
||
} catch (err) {
|
||
setError(err.kod === 'KOTA_ASILDI' ? err.message : `Yüklenemedi: ${err.message}`);
|
||
} finally { setYukleniyor(false); }
|
||
};
|
||
|
||
const belgeSil = async (id) => {
|
||
try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); }
|
||
};
|
||
|
||
const okunabilirMi = readableDocument;
|
||
|
||
const metniIncele = async (b) => {
|
||
setBusy('metin'); setError(null); setMetinOnizleme(null);
|
||
const token = getToken();
|
||
const request = ++onizlemeIstegi.current;
|
||
try {
|
||
const result = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
|
||
if (token !== getToken() || request !== onizlemeIstegi.current) return;
|
||
setMetinOnizleme({...result,name:b.ad,documentId:b.id,type:b.tur});
|
||
} catch (e) { if (token === getToken() && request === onizlemeIstegi.current) setError(e.message); }
|
||
finally { if (token === getToken() && request === onizlemeIstegi.current) setBusy(null); }
|
||
};
|
||
|
||
const kalemleriYenidenCikar = async (b) => {
|
||
setBusy('kalem'); setError(null); setBilgi(null);
|
||
try {
|
||
const result = await retryDocumentExtraction(b,{signal:readLifetime.current.signal});
|
||
setBilgi({ isleme: `${b.ad}: ${result.sayi || 0} kalem çıkarıldı.` });
|
||
} catch (e) { setError(`Kalem çıkarımı tamamlanamadı: ${e.message}. Dosya kaydı korunmuştur.`); }
|
||
finally { await belgeleriYukle(); setBusy(null); }
|
||
};
|
||
|
||
// Yüklenen PDF'i AI'ya ver
|
||
const belgeyiAnalizEt = async (b, mod) => {
|
||
setBusy('belge'); setError(null);
|
||
try {
|
||
if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).');
|
||
const reading = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
|
||
const metin = reading.text;
|
||
if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).');
|
||
assertCurrent();
|
||
setIsAiSpecAnalyzerOpen(true);
|
||
await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, extraction:{ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad}, tender, mode: mod });
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally { setBusy(null); }
|
||
};
|
||
const ekapIhalesi = /^\d{4}\/\d+$/.test(String(ikn || ''));
|
||
|
||
const dokumaniAc = async () => {
|
||
setBusy('url'); setError(null);
|
||
// Açılır pencere engelleyici: yeni sekme, tıklamanın HEMEN içinde
|
||
// (await'ten önce) açılmalı. Sunucu yanıtından sonra çağrılan window.open
|
||
// Safari, PWA ve sıkı ayarlı Chrome'da sessizce engelleniyor — kullanıcı
|
||
// "düğme çalışmıyor / dokümanlar açılmıyor" görüyordu. Sekme boş açılır,
|
||
// adres gelince yönlendirilir; engellenirse aşağıda gerçek bağlantı çıkar.
|
||
let sekme = null;
|
||
try { sekme = window.open('', '_blank'); } catch { sekme = null; }
|
||
if (sekme) {
|
||
try {
|
||
sekme.opener = null;
|
||
sekme.document.write('<p style="font-family:sans-serif;padding:24px;color:#334155">EKAP doküman sayfasına yönlendiriliyor…</p>');
|
||
} catch { /* yazılamazsa da yönlendirme çalışır */ }
|
||
}
|
||
try {
|
||
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
||
const d = await r.json();
|
||
if (!d.success) throw new Error(d.error);
|
||
setEkapUrl(d.url);
|
||
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
|
||
if (sekme) sekme.location.href = d.url;
|
||
} catch (e) {
|
||
if (sekme) { try { sekme.close(); } catch { /* yoksay */ } }
|
||
setError(e.message);
|
||
} finally { setBusy(null); }
|
||
};
|
||
|
||
const ilanMetniGetir = async () => {
|
||
assertCurrent();
|
||
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
|
||
signal:readLifetime.current.signal,
|
||
headers: { Authorization: `Bearer ${sessionToken.current}` },
|
||
});
|
||
const d = await r.json();
|
||
assertCurrent();
|
||
if (!d.success) throw new Error(d.error);
|
||
return d;
|
||
};
|
||
|
||
const ilaniAnalizEt = async () => {
|
||
setBusy('analiz'); setError(null);
|
||
try {
|
||
const d = await ilanMetniGetir();
|
||
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
|
||
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
|
||
assertCurrent();
|
||
setIsAiSpecAnalyzerOpen(true);
|
||
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally { setBusy(null); }
|
||
};
|
||
|
||
/**
|
||
* Derin analiz — teknik şartname ve diğer evrakın tamamını TEK metinde
|
||
* birleştirip gönderir: EKAP ilan metni + bu ihaleye yüklenmiş her belge.
|
||
* Böylece model şartnameyi ilanla ve eklerle çapraz okuyabiliyor.
|
||
*/
|
||
const tumEvrakiTopla = async () => {
|
||
const parcalar = [];
|
||
const atlanan = [];
|
||
const extraction = [];
|
||
|
||
if (ekapIhalesi) {
|
||
setIlerleme('EKAP ilan metni alınıyor…');
|
||
try {
|
||
const d = await ilanMetniGetir();
|
||
if (d.metin && d.metin.length >= 100) {
|
||
parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`);
|
||
}
|
||
} catch {
|
||
assertCurrent();
|
||
atlanan.push('EKAP ilan metni (alınamadı)');
|
||
extraction.push({fileName:'EKAP ilan metni',complete:false});
|
||
}
|
||
}
|
||
|
||
for (const b of belgeler) {
|
||
assertCurrent();
|
||
if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); extraction.push({fileName:b.ad,complete:false}); continue; }
|
||
setIlerleme(`${b.ad} okunuyor…`);
|
||
try {
|
||
const reading = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
|
||
const t = reading.text;
|
||
extraction.push({ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad});
|
||
if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`);
|
||
else { atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); extraction.push({fileName:b.ad,complete:false}); }
|
||
} catch (e) {
|
||
assertCurrent();
|
||
atlanan.push(`${b.ad} (${e.message})`);
|
||
extraction.push({fileName:b.ad,complete:false});
|
||
}
|
||
}
|
||
|
||
return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan, extraction };
|
||
};
|
||
|
||
const derinAnalizEt = async () => {
|
||
setBusy('derin'); setError(null); setBilgi(null);
|
||
try {
|
||
const { metin, parcaSayisi, atlanan, extraction } = await tumEvrakiTopla();
|
||
if (!metin || metin.length < 200) {
|
||
throw new Error('Derin analiz için yeterli metin toplanamadı. EKAP dokümanını indirip PDF olarak yükleyin.');
|
||
}
|
||
setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } });
|
||
setIlerleme('');
|
||
assertCurrent();
|
||
setIsAiSpecAnalyzerOpen(true);
|
||
await analyzeSpecPdfWithGemini({
|
||
text: metin,
|
||
extraction,
|
||
fileName: `${ikn || 'ihale'} — ${parcaSayisi} evrak birlikte`,
|
||
tender,
|
||
mode: 'derin',
|
||
});
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally { setBusy(null); setIlerleme(''); }
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||
<h4 className="font-bold text-sm text-slate-900 flex items-center gap-2">
|
||
<FileText className="w-4 h-4 text-blue-600" /> İhale Dokümanları
|
||
</h4>
|
||
|
||
{!ekapIhalesi ? (
|
||
<p className="text-xs text-slate-500 leading-relaxed">
|
||
Bu kayıt EKAP İKN formatında değil; doküman bağlantısı yalnızca EKAP ihaleleri için üretilebilir.
|
||
{tender?.kaynakUrl && (
|
||
<> İlanın kaynağını{' '}
|
||
<a href={tender.kaynakUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-semibold hover:underline">
|
||
buradan açabilirsiniz
|
||
</a>.
|
||
</>
|
||
)}
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="text-xs text-slate-500 leading-relaxed">
|
||
İhale dokümanları EKAP üzerinde tutulur. Aşağıdaki bağlantı sizi doğrudan bu ihalenin
|
||
EKAP doküman sayfasına götürür; EKAP önce kısa bir <strong>güvenlik doğrulaması</strong> (resim seçme)
|
||
ister, ardından şartname ve ekleri listelenir. İlan metnini ise analiz için buradan çekebilirsiniz.
|
||
</p>
|
||
|
||
<div className="flex gap-2 flex-wrap">
|
||
<button type="button"
|
||
onClick={dokumaniAc}
|
||
disabled={busy !== null}
|
||
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||
>
|
||
{busy === 'url' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5" />}
|
||
EKAP Dokümanlarını Aç
|
||
</button>
|
||
|
||
<button type="button"
|
||
onClick={ilaniAnalizEt}
|
||
disabled={busy !== null}
|
||
className="bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||
>
|
||
{busy === 'analiz' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||
İlan Metnini AI ile Analiz Et
|
||
</button>
|
||
</div>
|
||
|
||
{ekapUrl && (
|
||
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
|
||
<ExternalLink className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
|
||
<p className="text-[11px] text-slate-700 leading-relaxed">
|
||
{bilgi?.ekapSekme === 'engellendi'
|
||
? <>Tarayıcınız yeni sekmeyi engelledi. </>
|
||
: <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: </>}
|
||
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
|
||
EKAP doküman sayfasını aç
|
||
</a>
|
||
{' '}· Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{bilgi && !bilgi.derin && (
|
||
<p className="text-[11px] text-slate-500">
|
||
{bilgi.dokumanSayisi > 0 && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>}
|
||
{bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi. </>}
|
||
{bilgi.yuklendi && (
|
||
<>
|
||
<strong>{bilgi.yuklendi}</strong> yüklendi ve bu ihaleye bağlandı.
|
||
{bilgi.kalemHata && <span role="alert"> Kalem aktarımı tamamlanamadı: {bilgi.kalemHata}. Dosya kaydı korunmuştur.</span>}
|
||
{bilgi.kalem
|
||
? <> Teklif cetvelinden <strong>{bilgi.kalem} kalem</strong> otomatik çıkarıldı; bu ihale artık kalemleriyle listeleniyor.</>
|
||
: <> Bu dosyadan teklif cetveli kalemi çıkarılamadı.</>}
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
|
||
|
||
|
||
</>
|
||
)}
|
||
|
||
{/* EKAP dokümanı kullanıcının tarayıcısına iniyor; sunucu tarafından
|
||
alınamıyor. İndirilen dosya buraya yüklenip ihaleye bağlanır. */}
|
||
<div className="border border-dashed border-slate-300 rounded-xl p-3 bg-slate-50/60">
|
||
<p className="text-[11px] text-slate-600 leading-relaxed flex items-start gap-1.5">
|
||
<Download className="w-3.5 h-3.5 shrink-0 mt-0.5 text-slate-400" />
|
||
<span>
|
||
EKAP dokümanı kendi oturumunuz üzerinden <strong>bilgisayarınıza iner</strong> — biz sunucudan
|
||
alamıyoruz. İnen dosyayı buraya yükleyin; bu ihaleye bağlanır ve AI ile analiz edebilirsiniz.
|
||
</span>
|
||
</p>
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<input
|
||
ref={dosyaRef}
|
||
type="file"
|
||
onChange={dosyaSec}
|
||
accept=".pdf,.docx,.xlsx,.zip,.txt"
|
||
className="hidden"
|
||
/>
|
||
<button type="button"
|
||
onClick={() => dosyaRef.current?.click()}
|
||
disabled={yukleniyor}
|
||
className="bg-slate-900 hover:bg-slate-800 disabled:opacity-40 text-white font-bold text-xs px-3.5 py-2 rounded-lg flex items-center gap-2"
|
||
>
|
||
{yukleniyor ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||
{yukleniyor ? 'Yükleniyor…' : 'İndirdiğim dosyayı yükle'}
|
||
</button>
|
||
<span className="text-[10px] text-slate-400">PDF, DOCX, XLSX, ZIP · en fazla 10 MB</span>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||
<p className="text-[11px] text-red-800 leading-relaxed">{error}</p>
|
||
</div>
|
||
)}
|
||
{bilgi?.isleme && <p role="status" className="text-xs text-slate-700">{bilgi.isleme}</p>}
|
||
{listeBekliyor && <p role="status" className="text-xs text-slate-500">Belge listesi yükleniyor…</p>}
|
||
{listeHata && <div role="alert" className="text-xs text-red-700">
|
||
Belge listesi alınamadı: {listeHata}
|
||
<button type="button" onClick={belgeleriYukle} className="ml-2 underline">Listeyi yeniden yükle</button>
|
||
</div>}
|
||
{belgeler.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<p className="text-[11px] font-bold text-slate-700 flex items-center gap-1.5">
|
||
<Paperclip className="w-3.5 h-3.5 text-slate-400" /> Bu ihaleye yüklenen belgeler ({belgeler.length})
|
||
</p>
|
||
{belgeler.map((b) => (
|
||
<div key={b.id} className="flex flex-wrap items-center gap-2 bg-white border border-slate-200 rounded-lg px-2.5 py-2">
|
||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||
<div className="flex-1 min-w-32"><span className="block text-xs text-slate-800 break-all">{b.ad}</span><span className="block text-[10px] text-slate-500">{documentProcessingLabel(b.isleme)}</span></div>
|
||
<span className="text-[10px] text-slate-400 whitespace-nowrap">
|
||
{(b.boyut / 1024).toLocaleString('tr-TR', { maximumFractionDigits: 0 })} KB
|
||
</span>
|
||
<button type="button"
|
||
onClick={() => belgeyiAnalizEt(b)}
|
||
disabled={busy !== null}
|
||
className="text-[10px] font-bold text-amber-700 hover:text-amber-800 disabled:opacity-40 whitespace-nowrap"
|
||
>
|
||
{busy === 'belge' ? '…' : 'AI analiz'}
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => belgeyiAnalizEt(b, 'derin')}
|
||
disabled={busy !== null}
|
||
className="text-[10px] font-bold text-indigo-700 hover:text-indigo-800 disabled:opacity-40 whitespace-nowrap"
|
||
title="Yalnızca bu belge için ince detaylar + yol haritası"
|
||
>
|
||
Derin
|
||
</button>
|
||
{okunabilirMi(b.ad) && <button type="button" onClick={() => metniIncele(b)} disabled={busy !== null || !b.erisilebilir}
|
||
aria-label={`${b.ad} metnini incele`} className="text-[10px] text-blue-700 disabled:opacity-40">Metni incele</button>}
|
||
{b.tur === 'ihale' && okunabilirMi(b.ad) && (
|
||
<button type="button" onClick={() => kalemleriYenidenCikar(b)} disabled={busy !== null || yukleniyor || !b.erisilebilir}
|
||
aria-label={`${b.ad} için kalem çıkarımını yeniden dene`}
|
||
className="text-[10px] font-bold text-blue-700 disabled:opacity-40">
|
||
{busy === 'kalem' ? 'İşleniyor…' : 'Kalemleri yeniden çıkar'}
|
||
</button>
|
||
)}
|
||
<button type="button" disabled={busy !== null || yukleniyor} onClick={() => belgeSil(b.id)} className="text-slate-300 hover:text-red-600" aria-label="Sil">
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{metinOnizleme && <section aria-label="Okunan belge metni" className="border rounded-xl p-3 space-y-2">
|
||
<h5 className="font-bold text-xs">{metinOnizleme.name} — okunan metin</h5>
|
||
<p className="text-xs text-slate-600">
|
||
{metinOnizleme.pageCount != null ? `${metinOnizleme.usedPages}/${metinOnizleme.pageCount} sayfa okundu. ` : 'Sunucuda okunan belge metni. '}
|
||
{metinOnizleme.ocr ? `OCR kullanılan sayfalar: ${metinOnizleme.pages.filter(p=>p.method === 'ocr').map(p=>p.page).join(', ')}. ` : ''}
|
||
Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
|
||
</p>
|
||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs">{metinOnizleme.text || 'Okunabilir metin bulunamadı.'}</pre>
|
||
{metinOnizleme.type === 'ihale' && <DocumentTextReview key={metinOnizleme.documentId} documentId={metinOnizleme.documentId} extractedText={metinOnizleme.text} onAnalyze={async(reviewedSource)=>{
|
||
assertCurrent();
|
||
await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name});
|
||
assertCurrent();
|
||
setIsAiSpecAnalyzerOpen(true);
|
||
}} />}
|
||
<button type="button" onClick={()=>setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat</button>
|
||
</section>}
|
||
|
||
{(ekapIhalesi || belgeler.length > 0) && (
|
||
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-xl p-4 space-y-2.5">
|
||
<div className="flex items-start gap-2.5">
|
||
<div className="w-9 h-9 rounded-xl bg-amber-400 text-slate-950 flex items-center justify-center shrink-0">
|
||
<Route className="w-5 h-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h5 className="font-black text-sm text-white">Derin Analiz & Yol Haritası</h5>
|
||
<p className="text-[11px] text-slate-300 leading-relaxed">
|
||
EKAP ilan metni ve bu ihaleye yüklediğiniz tüm evrak <strong className="text-white">birlikte</strong> okunur;
|
||
elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve
|
||
teklife kadar adım adım yol haritası çıkarılır.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button"
|
||
onClick={derinAnalizEt}
|
||
disabled={busy !== null}
|
||
className="w-full bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center justify-center gap-2"
|
||
>
|
||
{busy === 'derin'
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
: <Sparkles className="w-3.5 h-3.5" />}
|
||
{busy === 'derin' ? (ilerleme || 'Analiz ediliyor…') : 'Tüm Evrakı Derin Analiz Et'}
|
||
</button>
|
||
|
||
{bilgi?.derin ? (
|
||
<div className="bg-black/30 rounded-lg p-2.5 space-y-1">
|
||
<p className="text-[10px] text-slate-300">
|
||
{bilgi.derin.parcaSayisi} kaynak · {bilgi.derin.karakter.toLocaleString('tr-TR')} karakter analize gönderildi.
|
||
</p>
|
||
{bilgi.derin.atlanan.length > 0 && (
|
||
<p className="text-[10px] text-amber-300 leading-relaxed">
|
||
Atlanan: {bilgi.derin.atlanan.join(', ')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p className="text-[10px] text-slate-400 text-center">
|
||
{belgeler.length > 0
|
||
? `${ekapIhalesi ? 'İlan metni + ' : ''}${belgeler.length} yüklü belge okunacak`
|
||
: 'Henüz belge yüklemediniz — yalnızca ilan metni okunacak'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
};
|