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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user