feat: derin analiz & yol haritasi + ihale dosyasi hazirlama; docx/xlsx/zip okunuyor

Doküman panelindeki "Tum Evraki Derin Analiz Et" dugmesi EKAP ilan metnini ve
ihaleye yuklenmis butun belgeleri TEK metinde birlestirip gonderiyor; model
sartnameyi ilanla ve eklerle capraz okuyabiliyor.

Derin analiz (mode='derin', ayri sistem istemi + 8000 token):
- Teklifi gecersiz kilabilecek noktalar (kirmizi uyari, en ustte)
- Gozden kacan ince detaylar (Kritik/Yuksek/Orta + madde no + neden onemli)
- Alinacak kalemler: aranan evsaflar, miktar, sartnamede geciyorsa fiyat
  (modele "TAHMIN URETME" talimati verildi, yoksa null)
- Belge kontrol listesi, kritik tarihler, maliyet kalemleri
- Teklife kadar yol haritasi (faz / kim / ne zaman / cikti)
- Idareye sorulacak belirsizlikler
Standart hizli ozet modu aynen duruyor.

Ihale Dosyasi Hazirla (rapor sonunda, acilir bolum):
- Eksik firma bilgileri kullaniciya sorulur, profilden gelenler sorulmaz,
  girilenler tarayicida saklanir
- Kalem fiyatlandirma tablosu, canli toplam + yaziyla karsiligi
- Teklif Mektubu, Birim Fiyat Teklif Cetveli, Evrak Kontrol Listesi
- Belgeler yazdirilabilir HTML: jsPDF'in gomulu fontlari WinAnsi, s/g/i/I
  tasimiyor (bkz. services/pdf.js ASCII basliklari) — resmi teklif mektubunda
  bozuk Turkce kabul edilemez

docx/xlsx/zip metin cikarimi (server/office-text.mjs):
- ihaleab projesindeki officeText.ts'ten tasindi, harici bagimlilik yok
- Sunucuda: tarayicida cozucu yok, baytlar zaten BELGE_DIR'de
- Tablolar " | " ile korunuyor, ic ice zip aciliyor, ~$ gecici dosyalari atlanir
- Yeni uc: GET /api/me/documents/text
- Yukleme kabul listesi .doc/.rar cikarildi (karsiligi yok), .xlsx eklendi

fix: yuklenen belgelerdeki "AI analiz" dugmesi bozuktu — extractText nesne
donduruyor ({text,pageCount,usedPages}), kod onu duz metin sanip gonderiyordu;
sunucuya "[object Object]" gidiyor ve analiz reddediliyordu. Uzunluk kontrolu
de nesne uzerinde undefined verdigi icin hatayi yutuyordu.

fix: analiz dokuman panelinden baslatildiginda modal kendi isAnalyzing'ini
bilmiyordu, kullanici 1-2 dakika bos modal goruyordu — ortak aiAnalyzing sinyali
This commit is contained in:
2026-08-31 14:53:46 +03:00
parent be79f9366a
commit 3eb0f91823
10 changed files with 1584 additions and 51 deletions
+329
View File
@@ -0,0 +1,329 @@
import React, { useState, useMemo } from 'react';
import { useApp } from '../context/AppContext';
import {
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
AlertTriangle, Printer, Info,
} from 'lucide-react';
import {
yaziylaTL, paraTR, miktarAyristir, belgeYazdir,
teklifMektubuHtml, teklifCetveliHtml, evrakListesiHtml,
} from '../services/bidDocs';
/**
* İhale Dosyası Hazırla — derin analiz bittikten sonra teklif zarfına girecek
* belgeleri üretir. Analizden gelen kalemler fiyatlandırılır, eksik firma
* bilgileri kullanıcıya sorulur; belgeler tam Türkçe olarak yazdırılır.
*
* Firma bilgileri localStorage'da tutulur — her ihalede yeniden sorulmasın.
*/
const ALANLAR = [
{ k: 'firmaUnvan', l: 'Firma ticaret unvanı', zorunlu: true, genis: true },
{ k: 'adres', l: 'Tebligata esas adres', zorunlu: true, genis: true },
{ k: 'vergiDairesi', l: 'Vergi dairesi', zorunlu: true },
{ k: 'vergiNo', l: 'Vergi numarası', zorunlu: true },
{ k: 'sicilNo', l: 'Ticaret sicil / MERSİS no', zorunlu: false },
{ k: 'yetkiliAd', l: 'Yetkili ad soyad', zorunlu: true },
{ k: 'yetkiliGorev', l: 'Yetkilinin görevi', zorunlu: true, ph: 'Örn. Genel Müdür' },
{ k: 'telefon', l: 'Telefon', zorunlu: true },
{ k: 'eposta', l: 'E-posta', zorunlu: true },
];
const FIRMA_ANAHTAR = 'ihaleist_firma_bilgi';
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender }) => {
const { userProfile } = useApp();
const [acik, setAcik] = useState(false);
const [hata, setHata] = useState(null);
const [firma, setFirma] = useState(() => {
let kayitli = {};
try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR) || '{}'); } catch { /* yoksay */ }
return {
...bosFirma(),
firmaUnvan: userProfile?.company || '',
...kayitli,
};
});
const [teklifTuru, setTeklifTuru] = useState('birim');
const [gecerlilikGun, setGecerlilikGun] = useState('60');
const [kisim, setKisim] = useState('');
const [goturuBedel, setGoturuBedel] = useState('');
const kalemler = Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : [];
const [fiyatlar, setFiyatlar] = useState({});
const alanYaz = (k, v) => {
const yeni = { ...firma, [k]: v };
setFirma(yeni);
try { localStorage.setItem(FIRMA_ANAHTAR, JSON.stringify(yeni)); } catch { /* kota */ }
};
const satirlar = useMemo(
() => kalemler.map((k, i) => ({ ...k, birimFiyat: fiyatlar[i] })),
[kalemler, fiyatlar],
);
const kalemToplami = useMemo(
() => satirlar.reduce((t, r) => {
const { miktar } = miktarAyristir(r.quantity);
return t + (miktar ?? 0) * (Number(r.birimFiyat) || 0);
}, 0),
[satirlar],
);
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
const hazir = eksikler.length === 0 && toplam > 0;
const formVerisi = () => ({
...firma,
teklifTuru,
gecerlilikGun,
kisim,
idare: tender?.kurum || '',
isinAdi: tender?.baslik || '',
ikn: tender?.ikn || '',
ihaleTarihi: tender?.ihaleTarihi || tender?.tarih || '',
});
const uret = (tip) => {
setHata(null);
try {
const f = formVerisi();
if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam));
if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam));
if (tip === 'liste') belgeYazdir('Evrak Kontrol Listesi', evrakListesiHtml(f, results?.documentChecklist || []));
} catch (e) {
setHata(e.message);
}
};
return (
<div className="bg-white rounded-2xl border-2 border-emerald-300 shadow-xs overflow-hidden">
<button
type="button"
onClick={() => setAcik((o) => !o)}
className="w-full flex items-center gap-3 p-5 text-left hover:bg-emerald-50/50 transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
<FolderCheck className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-black text-sm text-slate-900">İhale Dosyası Hazırla</h4>
<p className="text-[11px] text-slate-500 leading-relaxed">
Analizdeki kalemleri fiyatlandırın; teklif mektubu, birim fiyat teklif cetveli ve
evrak kontrol listesi hazır olsun.
</p>
</div>
{acik ? <ChevronUp className="w-5 h-5 text-slate-400 shrink-0" />
: <ChevronDown className="w-5 h-5 text-slate-400 shrink-0" />}
</button>
{acik && (
<div className="border-t border-emerald-200 p-5 space-y-5">
{/* Sizden beklenen bilgiler */}
{eksikler.length > 0 && (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-3.5 flex gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-xs font-bold text-amber-900">Sizden beklenen bilgiler</p>
<p className="text-[11px] text-amber-800 leading-relaxed mt-0.5">
Belgeleri hazırlayabilmek için şunlar gerekli:{' '}
<strong>{eksikler.map((a) => a.l).join(', ')}</strong>.
Bir kez girdiğinizde bu tarayıcıda saklanır, sonraki ihalelerde tekrar sorulmaz.
</p>
</div>
</div>
)}
{/* Firma bilgileri */}
<div className="space-y-2">
<h5 className="font-extrabold text-xs text-slate-900">İstekli (firma) bilgileri</h5>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{ALANLAR.map((a) => {
const eksik = a.zorunlu && !String(firma[a.k] || '').trim();
return (
<label key={a.k} className={a.genis ? 'sm:col-span-2' : ''}>
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
{a.l}{a.zorunlu && <span className="text-red-600"> *</span>}
</span>
<input
value={firma[a.k] || ''}
onChange={(e) => alanYaz(a.k, e.target.value)}
placeholder={a.ph || ''}
className={`w-full text-xs px-3 py-2 rounded-lg border bg-white focus:ring-2 focus:ring-emerald-500 outline-none ${
eksik ? 'border-amber-400 bg-amber-50/40' : 'border-slate-300'
}`}
/>
</label>
);
})}
</div>
</div>
{/* Teklif ayarları */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
<label>
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Teklif türü</span>
<select
value={teklifTuru}
onChange={(e) => setTeklifTuru(e.target.value)}
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300 bg-white"
>
<option value="birim">Birim fiyat</option>
<option value="goturu">Anahtar teslim götürü bedel</option>
</select>
</label>
<label>
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Teklif geçerlilik (gün)</span>
<input
value={gecerlilikGun}
onChange={(e) => setGecerlilikGun(e.target.value.replace(/\D/g, ''))}
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300"
/>
</label>
<label>
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Kısım (varsa)</span>
<input
value={kisim}
onChange={(e) => setKisim(e.target.value)}
placeholder="Örn. 2. kısım"
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300"
/>
</label>
</div>
{/* Fiyatlandırma */}
{teklifTuru === 'goturu' ? (
<label className="block">
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
Teklif edilen toplam bedel (KDV hariç, TL)
</span>
<input
value={goturuBedel}
onChange={(e) => setGoturuBedel(e.target.value.replace(/[^\d.]/g, ''))}
placeholder="0"
className="w-full sm:w-64 text-sm font-bold px-3 py-2 rounded-lg border border-slate-300"
/>
</label>
) : kalemler.length > 0 ? (
<div className="space-y-2">
<h5 className="font-extrabold text-xs text-slate-900">Kalem fiyatlandırma</h5>
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[520px]">
<thead>
<tr className="text-[11px] text-slate-500 border-b border-slate-200">
<th className="text-left py-1.5 font-semibold">Kalem</th>
<th className="text-right py-1.5 font-semibold w-24">Miktar</th>
<th className="text-right py-1.5 font-semibold w-32">Birim fiyat</th>
<th className="text-right py-1.5 font-semibold w-28">Tutar</th>
</tr>
</thead>
<tbody>
{satirlar.map((r, i) => {
const { miktar, birim } = miktarAyristir(r.quantity);
const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0);
return (
<tr key={i} className="border-b border-slate-100">
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
{miktar ?? '—'} {birim}
</td>
<td className="py-1.5 text-right">
<input
value={fiyatlar[i] ?? ''}
onChange={(e) =>
setFiyatlar((o) => ({ ...o, [i]: e.target.value.replace(/[^\d.]/g, '') }))}
placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'}
className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300"
/>
</td>
<td className="py-1.5 text-right font-semibold text-slate-900 whitespace-nowrap">
{tutar ? paraTR(tutar) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{kalemler.some((k) => k.estimatedUnitPrice != null) && (
<p className="text-[10px] text-slate-400 flex items-start gap-1.5">
<Info className="w-3 h-3 shrink-0 mt-0.5" />
Gri yazılı değerler şartnamede geçen tahmini birim fiyatlardır — teklifiniz değildir.
</p>
)}
</div>
) : (
<p className="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg p-3">
Analizde kalem dökümü çıkmadı. Götürü bedel seçip toplam teklifinizi doğrudan girebilirsiniz.
</p>
)}
{/* Toplam */}
<div className="bg-slate-900 text-white rounded-xl p-4 space-y-1">
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span className="text-xs font-semibold text-slate-300">Teklif toplamı (KDV hariç)</span>
<span className="text-lg font-black text-amber-300">{paraTR(toplam)} TL</span>
</div>
{toplam > 0 && (
<p className="text-[11px] text-slate-400 leading-relaxed">Yazı ile: {yaziylaTL(toplam)}</p>
)}
</div>
{hata && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 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">{hata}</p>
</div>
)}
{/* Belgeler */}
<div className="space-y-2">
<h5 className="font-extrabold text-xs text-slate-900">Hazırlanacak belgeler</h5>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{[
{ t: 'mektup', ad: 'Teklif Mektubu', ikon: FileSignature, aciklama: 'Rakam ve yazı ile bedel' },
{ t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${kalemler.length} kalem` },
{ t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${(results?.documentChecklist || []).length} belge` },
].map((b) => (
<button
key={b.t}
type="button"
disabled={!hazir}
onClick={() => uret(b.t)}
className="text-left bg-white border border-slate-300 hover:border-emerald-500 hover:bg-emerald-50/50 disabled:opacity-40 disabled:hover:border-slate-300 disabled:hover:bg-white rounded-xl p-3 transition-colors"
>
<b.ikon className="w-4 h-4 text-emerald-600 mb-1.5" />
<span className="block text-xs font-bold text-slate-900 leading-snug">{b.ad}</span>
<span className="block text-[10px] text-slate-500 mt-0.5">{b.aciklama}</span>
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 mt-1.5">
<Printer className="w-3 h-3" /> Yazdır / PDF
</span>
</button>
))}
</div>
{!hazir && (
<p className="text-[11px] text-slate-500">
{eksikler.length > 0
? 'Yukarıdaki zorunlu alanları doldurun.'
: 'Belgeleri hazırlamak için teklif tutarını girin.'}
</p>
)}
</div>
<p className="text-[10px] text-slate-400 leading-relaxed">
Belgeler taslaktır ve idarenin standart formlarının yerine geçmez. İmzalamadan önce
ihale dokümanındaki form metinleriyle karşılaştırın.
</p>
</div>
)}
</div>
);
};