Files
ihaleist/src/components/AiSpecAnalyzerModal.jsx
T

386 lines
22 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 { AiJobHistory } from './AiJobHistory';
import {getToken} from '../services/auth';
import React, { useState, useEffect, useId } from 'react';
import {useModalFocus} from '../hooks/useModalFocus';
import { useApp } from '../context/AppContext';
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
import confetti from 'canvas-confetti';
import { getAiConfig } from '../services/ai';
import { DeepAnalysisReport } from './DeepAnalysisReport';
const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—');
export const AiSpecAnalyzerModal = () => {
const {
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, aiJobStatus, activeAiModel, activeTenderDetail,
} = useApp();
const dialogTitleId=useId();
const modalFocus=useModalFocus(isAiSpecAnalyzerOpen,()=>setIsAiSpecAnalyzerOpen(false));
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [error, setError] = useState(null);
const [progress, setProgress] = useState('');
const [restoredSource,setRestoredSource]=useState(null);
const [customBid, setCustomBid] = useState(null);
const [aiCfg, setAiCfg] = useState(null);
// Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir.
// Çıktısı standart şemanın üst kümesi olduğu için varsayılan açık.
const [derin, setDerin] = useState(true);
useEffect(() => {
if (isAiSpecAnalyzerOpen && aiCfg === null) getAiConfig().then(setAiCfg);
}, [isAiSpecAnalyzerOpen, aiCfg]);
if (!isAiSpecAnalyzerOpen) return null;
// Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false
// kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz.
const mesgul = isAnalyzing || aiAnalyzing;
const results = !activeTenderDetail || aiAnalysisResults?.tenderKey===(activeTenderDetail.ikn || activeTenderDetail.id) ? aiAnalysisResults : null;
const bid = results?.bidAnalysis || {};
const runAnalysis = async (text, fileName) => {
setError(null);setRestoredSource(null);
setIsAnalyzing(true);
setSelectedFile(fileName);
try {
setProgress(derin ? 'Derin analiz üretiliyor (1-2 dk sürebilir)…' : 'Model analiz ediyor…');
const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail, mode: derin ? 'derin' : undefined });
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
confetti({ particleCount: 80, spread: 70 });
} catch (e) {
setError(e.message);
} finally {
setIsAnalyzing(false);
setProgress('');
}
};
const handleFile = async (file) => {
setError(null);setRestoredSource(null);
setIsAnalyzing(true);
setSelectedFile(file.name);
try {
setProgress('Belge metni çıkarılıyor…');
const { extractText } = await import('../services/pdfText');
const reading = await extractText(file);
const { text, usedPages, pageCount } = reading;
if (!text || text.length < 40) {
throw new Error('Belgeden metin çıkarılamadı. Taranmış (görüntü) PDF olabilir — metin tabanlı bir dosya deneyin.');
}
setProgress(`${usedPages}/${pageCount} sayfa okundu, ${derin ? 'derin analiz üretiliyor (1-2 dk sürebilir)…' : 'model analiz ediyor…'}`);
const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, extraction:{ocr:reading.ocr,pageCount,usedPages,complete:reading.complete,fileName:file.name}, tender: activeTenderDetail, mode: derin ? 'derin' : undefined });
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
confetti({ particleCount: 80, spread: 70 });
} catch (e) {
setError(e.message);
} finally {
setIsAnalyzing(false);
setProgress('');
}
};
// Açık ihalenin ilan metnini analiz et
const analyzeCurrentTender = () => {
const t = activeTenderDetail;
const text = [t?.baslik, t?.kurum, t?.aciklama, ...(t?.yeterlikKriterleri || [])].filter(Boolean).join('\n');
if (!text || text.length < 40) {
setError('Açık ihalenin ilan metni analiz için yeterli değil. Bir şartname dosyası yükleyin.');
return;
}
runAnalysis(text, `${t.ikn || 'ihale'} — ilan metni`);
};
const modelEtiketi = aiCfg?.providers?.length
? aiCfg.providers.map((p) => p.label).join(' → ')
: `${activeAiModel.label} (${activeAiModel.provider})`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
<div {...modalFocus} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden my-auto flex flex-col max-h-[92vh]">
{/* Header */}
<div className="bg-linear-to-r from-blue-900 via-indigo-900 to-purple-900 text-white px-6 py-4 flex items-center justify-between border-b border-indigo-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md font-black">
<Sparkles className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h2 id={dialogTitleId} className="font-extrabold text-base">Teknik Şartname & Teklif Analiz Asistanı</h2>
<span className="bg-amber-400 text-slate-900 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider">
{modelEtiketi}
</span>
</div>
<p className="text-xs text-blue-200">Şartname dosyanızı yükleyin; yeterlik, riskler ve teklif aralığı gerçek AI ile analiz edilsin.</p>
</div>
</div>
<button type="button" aria-label="Analiz ekranını kapat" onClick={() => setIsAiSpecAnalyzerOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<AiJobHistory key={`${authUser?.email}:${getToken()}:${activeTenderDetail?.ikn || ''}`} ikn={activeTenderDetail?.ikn}
operationFilter="analiz" locked={mesgul} revision={`${isAiSpecAnalyzerOpen}:${aiAnalyzing}:${aiJobStatus}`}
onOpen={async job=>{const result=await openSavedAnalysis(job);setRestoredSource({text:job.input.text,tenderKey:result.tenderKey});setSelectedFile(result.fileName || null);setCustomBid(result.bidAnalysis?.recommendedBid ?? null);setError(null);}} />
{results && restoredSource?.tenderKey===results.tenderKey && <details className="border rounded p-3 text-sm"><summary>Kaydedilen analiz kaynak metni</summary><pre className="whitespace-pre-wrap max-h-64 overflow-auto">{restoredSource.text || 'Bu eski kayıtta kaynak metni bulunmuyor.'}</pre></details>}
{mesgul&&<p role="status" className="text-sm text-blue-800">Analiz sunucuda sürüyor. Bu ekranı kapatabilirsiniz; sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.</p>}
{/* AI yapılandırılmamış uyarısı */}
{aiCfg && !aiCfg.configured && (
<p role="alert" className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded p-3">AI hizmeti şu anda kullanılamıyor. Kayıtlı sonuçlarınızı açabilirsiniz. Yeni analiz için daha sonra tekrar deneyin veya uygulama yöneticisine bildirin.</p>
)}
{/* Uploader */}
<div className="bg-slate-50 border-2 border-dashed border-blue-300 hover:border-blue-500 rounded-2xl p-6 text-center space-y-3 transition-colors">
<div className="w-12 h-12 rounded-2xl bg-blue-100 text-blue-600 flex items-center justify-center mx-auto shadow-xs">
<Upload className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-sm text-slate-800">Teknik Şartname Dosyası Yükleyin</h3>
<p className="text-xs text-slate-500">Metin tabanlı <code>.pdf</code> veya <code>.txt</code> dosyası — belge tarayıcınızda okunur, yalnızca metni analiz için gönderilir.</p>
</div>
<div className="inline-flex items-center gap-1 bg-white border border-slate-300 rounded-xl p-1">
<button
type="button"
disabled={mesgul}
onClick={() => setDerin(true)}
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50 ${
derin ? 'bg-amber-400 text-slate-950' : 'text-slate-600 hover:bg-slate-100'
}`}
>
Derin Analiz + Yol Haritası
</button>
<button
type="button"
disabled={mesgul}
onClick={() => setDerin(false)}
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50 ${
!derin ? 'bg-slate-800 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
Hızlı Özet
</button>
</div>
<p className="text-[11px] text-slate-500 leading-relaxed max-w-lg mx-auto">
{derin
? 'Elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve teklife kadar adım adım yol haritası üretilir. Daha uzun sürer.'
: 'Yalnızca özet, yeterlik kriterleri, riskler ve teklif aralığı üretilir. Daha hızlıdır.'}
</p>
<div className="flex justify-center gap-3 pt-2 flex-wrap">
<label className={`font-extrabold text-xs px-5 py-2.5 rounded-xl shadow-md transition-colors flex items-center gap-2 ${
mesgul ? 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500 text-white cursor-pointer'
}`}>
<Upload className="w-4 h-4" />
<span>Dosya Seç (PDF / TXT)</span>
<input
type="file"
accept=".pdf,.txt,.md"
className="hidden"
disabled={mesgul}
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
/>
</label>
{activeTenderDetail && (
<button
type="button"
disabled={mesgul}
onClick={analyzeCurrentTender}
className="bg-white hover:bg-slate-100 disabled:opacity-50 text-slate-700 font-bold text-xs px-4 py-2.5 rounded-xl border border-slate-300 transition-colors flex items-center gap-2"
>
<FileText className="w-4 h-4 text-purple-600" />
<span>Açık İhalenin İlan Metnini Analiz Et</span>
</button>
)}
</div>
</div>
{/* Hata */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-red-900">Analiz tamamlanamadı</p>
<p className="text-xs text-red-800 mt-0.5 leading-relaxed">{error}</p>
</div>
</div>
)}
{/* Yükleniyor */}
{mesgul && (
<div className="bg-linear-to-r from-blue-50 to-indigo-50 border border-blue-200 p-6 rounded-2xl space-y-2 animate-fade-in shadow-xs">
<div className="flex items-center gap-2 font-extrabold text-xs text-blue-900">
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
<span>{progress || 'Evraklar analiz ediliyor…'}</span>
</div>
<p className="text-xs text-slate-600">{selectedFile}</p>
</div>
)}
{/* Sonuç */}
{!mesgul && results && (
<div className="space-y-6 animate-fade-in">
<div className="bg-slate-900 text-white p-5 rounded-2xl space-y-3 shadow-md">
<div className="flex items-center justify-between border-b border-slate-800 pb-2 gap-2 flex-wrap">
<span className="text-xs font-extrabold text-amber-400 uppercase tracking-wider flex items-center gap-1.5">
<Sparkles className="w-4 h-4" /> Şartname Özeti
</span>
{results.mode === 'derin' && (
<span className="bg-amber-400 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider">
Derin Analiz
</span>
)}
<span className="text-[10px] text-slate-400">
{results.model} · {results.analyzedAt}
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed font-medium">{results.summary || 'Özet üretilemedi.'}</p>
{results.fileName && <p className="text-[10px] text-slate-500">Kaynak: {results.fileName}</p>}
</div>
<DeepAnalysisReport results={results} tender={activeTenderDetail} />
{/* Teklif analizi — yalnızca model sayı ürettiyse */}
{(bid.estimatedCost != null || bid.recommendedBid != null) && (
<div className="bg-linear-to-br from-emerald-950 via-slate-900 to-blue-950 text-white p-6 rounded-2xl space-y-4 shadow-xl border border-emerald-800/40">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" />
<h3 className="font-black text-base">Önerilen Teklif Aralığı</h3>
</div>
{bid.winProbability != null && (
<span className="bg-emerald-500 text-slate-950 font-black text-xs px-3 py-1 rounded-full shadow-xs">
%{bid.winProbability} Kazanma Tahmini
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="bg-white/10 p-3.5 rounded-xl border border-white/10">
<span className="text-[11px] text-slate-300 font-medium block">İdare Yaklaşık Maliyeti</span>
<span className="text-base font-black text-white mt-1 block">{tl(bid.estimatedCost)}</span>
</div>
<div className="bg-emerald-500/20 p-3.5 rounded-xl border border-emerald-400/40">
<span className="text-[11px] text-emerald-300 font-bold block">Önerilen Optimal Teklif</span>
<span className="text-xl font-black text-amber-300 mt-0.5 block">{tl(bid.recommendedBid)}</span>
</div>
<div className="bg-white/10 p-3.5 rounded-xl border border-white/10">
<span className="text-[11px] text-slate-300 font-medium block">Rekabet Değerlendirmesi</span>
<span className="text-xs font-bold text-blue-300 mt-1 block leading-snug">{bid.competitionLevel || '—'}</span>
</div>
</div>
{bid.minBidLimit != null && bid.maxBidLimit != null && bid.maxBidLimit > bid.minBidLimit && (
<div className="bg-black/30 p-4 rounded-xl space-y-2 border border-white/10">
<div className="flex justify-between items-center text-xs">
<span className="font-bold text-slate-300 flex items-center gap-1.5">
<Sliders className="w-4 h-4 text-amber-400" /> Teklif Fiyatınızı Simüle Edin:
</span>
<span className="font-black text-sm text-emerald-400">{tl(customBid ?? bid.recommendedBid)}</span>
</div>
<input
type="range"
min={bid.minBidLimit}
max={bid.maxBidLimit}
step={Math.max(1, Math.round((bid.maxBidLimit - bid.minBidLimit) / 200))}
value={customBid ?? bid.recommendedBid ?? bid.minBidLimit}
onChange={(e) => setCustomBid(Number(e.target.value))}
className="w-full accent-emerald-500 cursor-pointer h-2 bg-slate-800 rounded-lg"
/>
<div className="flex justify-between text-[10px] text-slate-400 font-medium">
<span>Min: {tl(bid.minBidLimit)}</span>
<span className="text-amber-300 font-bold">Önerilen: {tl(bid.recommendedBid)}</span>
<span>Maks: {tl(bid.maxBidLimit)}</span>
</div>
</div>
)}
{bid.notes && <p className="text-[11px] text-slate-400 leading-relaxed">{bid.notes}</p>}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
<h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600" /> Yeterlik Kriterleri & Zorunlu Belgeler
</h4>
<ul className="space-y-2 text-xs text-slate-700">
{(results.qualificationCriteria || []).length === 0 && (
<li className="text-slate-400">Bu bölüm için yapılandırılmış yeterlik listesi çıkarılamadı. Derin analizdeki belge ve şartları kontrol edin.</li>
)}
{(results.qualificationCriteria || []).map((item, idx) => (
<li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-600 mt-1.5 shrink-0"></span>
<span>{item}</span>
</li>
))}
</ul>
{(results.criticalRequirements || []).length > 0 && (
<>
<h4 className="font-extrabold text-xs text-slate-900 pt-2 border-t border-slate-100">Kritik Şartlar</h4>
<ul className="space-y-1.5 text-xs text-slate-700">
{results.criticalRequirements.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-600 mt-1.5 shrink-0"></span>
<span>{item}</span>
</li>
))}
</ul>
</>
)}
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
<h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2">
<AlertTriangle className="w-4 h-4 text-rose-600" /> Riskler & Cezai Hükümler
</h4>
<div className="space-y-2 text-xs">
{(results.risksAndPenalties || []).length === 0 && (
<p className="text-slate-400">Bu bölümde yapılandırılmış ceza/risk listesi yok. Bu, belgede risk bulunmadığı anlamına gelmez.</p>
)}
{(results.risksAndPenalties || []).map((risk, idx) => (
<div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-slate-900">{risk.clause || '—'}</span>
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded shrink-0 ${
risk.level === 'Yüksek' ? 'bg-red-100 text-red-800' :
risk.level === 'Orta' ? 'bg-amber-100 text-amber-800' : 'bg-slate-200 text-slate-800'
}`}>
{risk.level || 'Bilgi'} Risk
</span>
</div>
<p className="text-slate-600 leading-snug">{risk.text}</p>
</div>
))}
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 text-center leading-relaxed">
Analiz yapay zeka tarafından üretilmiştir; teklif kararı öncesi şartnamenin aslını doğrulayın.
</p>
</div>
)}
</div>
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-end">
<button
onClick={() => setIsAiSpecAnalyzerOpen(false)}
className="bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs px-6 py-2.5 rounded-xl transition-colors"
>
Kapat
</button>
</div>
</div>
</div>
);
};