Files
ihaleist/src/components/TenderDocuments.jsx
T
hikmettanandClaude Fable 5.1 2271883be4 fix: EKAP dokümanları açılmıyor — yeni sekme tıklama anında açılıyor, boşluklu İKN'ler düzeltildi
İki ayrı neden vardı:

1) window.open sunucu yanıtından SONRA çağrılıyordu; Safari, PWA ve sıkı ayarlı
   Chrome bunu açılır-pencere sayıp sessizce engelliyordu — düğme "çalışmıyor"
   görünüyordu. Sekme artık tıklamanın hemen içinde boş açılır, adres gelince
   yönlendirilir. Engellenirse gerçek bir bağlantı (a href) gösterilir; bağlantı
   tıklaması hiçbir tarayıcıda engellenmez.

2) ilan.gov.tr bazı kayıtlarda İKN'yi başta/sonda boşluk ve kesme boşluğu (nbsp)
   ile veriyor (" 2026/1622878"). Bu 50 kayıtta panel "EKAP formatında değil"
   deyip düğmeyi hiç göstermiyordu. İKN artık alınırken boşluksuz saklanıyor;
   panel ve doküman araması da savunma amaçlı temizliyor.

Ayrıca: "EKAP'ta 0 doküman kayıtlı" yanıltıcı metni kaldırıldı (EKAP arama
kaydı sayıyı vermiyor), EKAP'ın önce güvenlik doğrulaması istediği açıklandı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:53:43 +03:00

420 lines
19 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, 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 } 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 { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [bilgi, setBilgi] = useState(null);
const [ilerleme, setIlerleme] = useState('');
// 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] = useState(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] = useState([]);
const [yukleniyor, setYukleniyor] = useState(false);
const dosyaRef = useRef(null);
const belgeleriYukle = useCallback(() => {
if (!ikn) return;
listTenderDocuments(ikn).then(setBelgeler).catch(() => {});
}, [ikn]);
useEffect(() => { belgeleriYukle(); }, [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.
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn);
let kalem = cevap?.kalem?.sayi || null;
if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) {
try {
const { text } = await extractText(f);
const { kalemCikar } = await import('../services/kalem');
const d = await kalemCikar({ ikn, metin: text, dosya: f.name });
kalem = d.sayi || null;
} catch { /* kalem çıkmazsa yükleme yine de başarılı sayılır */ }
}
belgeleriYukle();
setBilgi({ yuklendi: f.name, kalem });
} 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); }
};
// PDF/metin tarayıcıda (pdfjs) okunur; docx/xlsx/zip için tarayıcıda çözücü
// yok, sunucudaki çıkarıcıya sorulur. .doc ve .rar'ın karşılığı yok.
const tarayicidaOkunur = (ad) => /\.(pdf|txt|md)$/i.test(String(ad || ''));
const sunucudaOkunur = (ad) => /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
const okunabilirMi = (ad) => tarayicidaOkunur(ad) || sunucudaOkunur(ad);
const belgeMetni = async (b) => {
if (sunucudaOkunur(b.ad)) {
const r = await fetch(`/api/me/documents/text?id=${encodeURIComponent(b.id)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
const d = await r.json().catch(() => ({}));
if (!d.success) throw new Error(d.error || 'metin çıkarılamadı');
return d.metin;
}
const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!r.ok) throw new Error('belge indirilemedi');
const blob = await r.blob();
// extractText { text, pageCount, usedPages } döndürür — nesneyi metin sanıp
// göndermek analizi sessizce bozuyordu.
const { text } = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' }));
return text;
};
// 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 metin = await belgeMetni(b);
if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).');
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ text: metin, 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 () => {
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
const d = await r.json();
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 });
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 = [];
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 {
atlanan.push('EKAP ilan metni (alınamadı)');
}
}
for (const b of belgeler) {
if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); continue; }
setIlerleme(`${b.ad} okunuyor…`);
try {
const t = await belgeMetni(b);
if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`);
else atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`);
} catch (e) {
atlanan.push(`${b.ad} (${e.message})`);
}
}
return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan };
};
const derinAnalizEt = async () => {
setBusy('derin'); setError(null); setBilgi(null);
try {
const { metin, parcaSayisi, atlanan } = 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('');
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({
text: metin,
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
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
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.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>
)}
{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>
)}
</>
)}
{/* 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
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>
{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 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" />
<span className="text-xs text-slate-800 truncate flex-1" title={b.ad}>{b.ad}</span>
<span className="text-[10px] text-slate-400 whitespace-nowrap">
{(b.boyut / 1024).toLocaleString('tr-TR', { maximumFractionDigits: 0 })} KB
</span>
<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
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>
<button 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>
)}
{(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 &amp; 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
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>
);
};