383 lines
20 KiB
React
383 lines
20 KiB
React
import React, { useState, useEffect } from 'react';
|
||
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, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
|
||
} = useApp();
|
||
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [selectedFile, setSelectedFile] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [progress, setProgress] = useState('');
|
||
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 = aiAnalysisResults;
|
||
const bid = results?.bidAnalysis || {};
|
||
|
||
const runAnalysis = async (text, fileName) => {
|
||
setError(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);
|
||
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 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 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 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">
|
||
|
||
{/* AI yapılandırılmamış uyarısı */}
|
||
{aiCfg && !aiCfg.configured && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
|
||
<Info className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
|
||
<p className="text-xs text-amber-900 leading-relaxed">
|
||
<strong>AI henüz yapılandırılmamış.</strong> En kolayı: sunucuya tek bir{' '}
|
||
<code className="bg-amber-100 px-1 rounded">OPENROUTER_API_KEY</code> ekleyin —
|
||
zincirdeki tüm modeller (GLM, DeepSeek, Gemini, Grok, GPT, Claude) bu tek anahtarla çalışır.
|
||
Dilerseniz tek tek sağlayıcı anahtarı da verebilirsiniz; doğrudan anahtar OpenRouter'ı ezer.
|
||
Model sırası Admin → AI Model Ayarları'ndan belirlenir.</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
};
|