import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook'; import { getToken } from '../services/auth'; import { listDocuments, downloadDocument } from '../services/account'; import { tenderDateKey } from '../services/tenderCalendar'; import React, { useState, useMemo, useEffect, useRef } from 'react'; import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation'; import { sourceRows, bidSourceSnapshot, restoreBidPrices, bidSourceOptions } from '../services/bidSource'; import { rowLot, selectBidLots } from '../services/bidLots'; import { PreparationChecks } from './PreparationChecks'; import { preparationBlockers, documentKey } from '../services/preparationChecks'; 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: selectedTender }) => { const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp(); const tender = {...selectedTender, ...tenders.find(t => t.id === selectedTender?.id)}; const storedDraft = tender?.bidDraft || {}; const [vault,setVault] = useState({loading:true,error:null,documents:[]}); const [vaultRefresh,setVaultRefresh] = useState(0); useEffect(() => { let cancelled=false; setVault({loading:true,error:null,documents:[]}); listDocuments().then(data=>{if(!cancelled)setVault({loading:false,error:null,documents:data.belgeler || []});}) .catch(error=>{if(!cancelled)setVault({loading:false,error:error.message,documents:[]});}); return ()=>{cancelled=true;}; },[authUser?.email,vaultRefresh]); const [dirty, setDirty] = useState(false); const [acik, setAcik] = useState(true); const [hata, setHata] = useState(null); const [firma, setFirma] = useState(() => { let kayitli = {}; try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR + ':' + authUser?.email) || '{}'); } catch { /* yoksay */ } return { ...bosFirma(), firmaUnvan: userProfile?.company || '', ...kayitli, ...(storedDraft.firma || {}), }; }); const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim'); const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || ''); const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik?.[1] || ''); const [kisim, setKisim] = useState(storedDraft.kisim || ''); const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || ''); const effectiveResults = results || storedDraft.analysisSource || {}; const [itemSource, setItemSource] = useState(storedDraft.itemSource || {}); const sourceOptions = bidSourceOptions(tender, effectiveResults, itemSource); const [manualDocuments, setManualDocuments] = useState(storedDraft.manualDocuments || []); const [checks, setChecks] = useState(storedDraft.checks || {}); const documents = [...new Map([...(effectiveResults.documentChecklist || []), ...manualDocuments].map(d => [documentKey(d),d])).values()]; const [partialBid,setPartialBid] = useState(storedDraft.partialBid || {enabled:false,source:'',selected:[],assignments:{}}); const kalemler = sourceRows(tender, effectiveResults, sourceOptions.selected || 'tender'); const lotSelection = selectBidLots(kalemler,partialBid); const allLots = [...new Set(kalemler.map(r=>rowLot(r,partialBid.assignments)).filter(Boolean))]; const sourceSnapshot = JSON.stringify([bidSourceSnapshot(tender, {...effectiveResults, documentChecklist:documents}, kalemler), partialBid, itemSource, sourceOptions.signature]); const [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null); const [fiyatlar, setFiyatlar] = useState(() => restoreBidPrices(storedDraft)); const kaynakKontrolGerekli = reviewedSource !== sourceSnapshot; const eskiFiyatlar = storedDraft.priceSchema !== 1 && Object.keys(storedDraft.fiyatlar || {}).length > 0; const alanYaz = (k, v) => { const yeni = { ...firma, [k]: v }; setFirma(yeni); try { localStorage.setItem(FIRMA_ANAHTAR + ':' + authUser?.email, JSON.stringify(yeni)); } catch { /* kota */ } }; const satirlar = useMemo( () => lotSelection.rows.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })), [kalemler, fiyatlar, partialBid], ); const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]); const latestDraft = useRef(''); latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]); const mounted = useRef(true); const printWindow = useRef(null); const [printing,setPrinting] = useState(false); useEffect(()=>{mounted.current=true;return ()=>{mounted.current=false;printWindow.current?.close();};},[]); const saveDraft = async () => { const submitted = latestDraft.current; const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null,sourceReview:effectiveResults.sourceReview || null}}}); if (!mounted.current) return; if (saved) { setDirty(latestDraft.current !== submitted); setHata(null); } else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; const toplam = teklifTuru === 'goturu' ? (parseBidPrice(goturuBedel) || 0) : kalemToplami; const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim()); const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0; const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || ''); const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1])); const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender)}); const analizEksik = effectiveResults.sourceReview?.status === 'partial'; const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection; const formVerisi = () => ({ ...firma, teklifTuru, gecerlilikGun, kisim: partialBid.enabled ? partialBid.selected.join(', ') : kisim, idare: tender?.kurum || '', isinAdi: tender?.baslik || '', ikn: tender?.ikn || '', ihaleTarihi: tender?.ihaleTarihi || tender?.tarih || '', }); const uret = async (tip) => { if (printWindow.current) return; const submitted = latestDraft.current; const session = getToken(); setHata(null); try { if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.'); // Open during the click; async validation must not lose popup activation. printWindow.current = belgeYazdir('Dosya kontrolü', '

Belgeler kontrol ediliyor…

'); setPrinting(true); const currentFiles = await listDocuments(); if (!mounted.current || session !== getToken() || submitted !== latestDraft.current) throw new Error('Form, kaynak veya oturum değişti. Güncel bilgilerle yeniden çıktı oluşturun.'); const currentVault = {loading:false,error:null,documents:currentFiles.belgeler || [],deadlineDate:tenderDateKey(tender)}; setVault(currentVault); const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault); if (fileErrors.length) throw new Error(fileErrors.join(' ')); const f = formVerisi(); if (tip === 'excel') { const buffer = await createBidWorkbook(f,satirlar); if (!mounted.current || session !== getToken() || submitted !== latestDraft.current) throw new Error('Form veya oturum değişti; Excel çıktısını yeniden oluşturun.'); downloadBidWorkbook(buffer,tender.ikn); printWindow.current?.close(); } if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam), printWindow.current); if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam), printWindow.current); if (tip === 'liste') belgeYazdir('Evrak Kontrol Listesi', evrakListesiHtml(f, documents), printWindow.current); printWindow.current = null; } catch (e) { printWindow.current?.close(); printWindow.current = null; if(mounted.current) setHata(e.message); } finally { if(mounted.current) setPrinting(false); } }; return (
{acik && (
setDirty(true)}>
{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}
{kaynakKontrolGerekli &&

{reviewedSource ? 'İlan, kalem veya belge bilgisi değişti. Önceki kontrol bu sürüm için geçerli değil.' : 'Kalemler ve teklif koşulları resmi dokümanla henüz karşılaştırılmadı.'} {' '}Kaynağı kontrol etmeden teklif çıktısı oluşturulamaz.

} {eskiFiyatlar &&

Eski taslak fiyatları satır sırasına göre kaydedilmiş. Yanlış ürüne aktarılmaması için fiyatları yeniden girin.

}
{sourceOptions.conflict &&
Kalem kaynakları farklı

İhale havuzundaki liste ile analiz listesi aynı değil. Resmi cetvelle karşılaştırıp kullanılacak listeyi seçin. Seçim uygunluk onayı değildir; ardından kalem, fiyat ve belge kontrollerini tamamlayın.

{sourceOptions.needsSelection &&

Kaynak seçilmeden çıktı hazırlanamaz. Kaynak listeleri değiştiğinde seçim yeniden yapılmalıdır.

}
} {analizEksik &&

Analiz kısmi: kaynak veya kalem listesi eksik. Tam dokümanı ve resmi cetveli kontrol ederek analizi tamamlayın.

} {/* Sizden beklenen bilgiler */} {eksikler.length > 0 && (

Sizden beklenen bilgiler

Belgeleri hazırlayabilmek için şunlar gerekli:{' '} {eksikler.map((a) => a.l).join(', ')}. Bir kez girdiğinizde bu tarayıcıda saklanır, sonraki ihalelerde tekrar sorulmaz.

)} {/* Firma bilgileri */}
İstekli (firma) bilgileri
{ALANLAR.map((a) => { const eksik = a.zorunlu && !String(firma[a.k] || '').trim(); return ( ); })}
{/* Teklif ayarları */}
{partialBid.enabled && <>

Resmi kısım numaralarını cetvelden doğrulayın. Aynı kısımdaki tüm kalemler birlikte seçilir; seçilmeyenler toplam ve çıktı dışında kalır.

{allLots.map(lot=>)}
{kalemler.map(row=>)}
{lotSelection.errors.map(message=>

{message}

)} }
{/* Fiyatlandırma */} {teklifTuru === 'goturu' ? ( ) : lotSelection.rows.length > 0 ? (
Kalem fiyatlandırma

Ondalık fiyatı 125,50 veya 125.50 biçiminde yazın; binlik ayırıcı kullanmayın.

{satirlar.map((r, i) => { const { miktar, birim } = miktarAyristir(r.quantity); const tutar = bidRowTotal(r); return ( ); })}
Kalem Miktar Birim fiyat Tutar
{r.name || '—'} {miktar ?? '—'} {birim} setFiyatlar((o) => ({ ...o, [r.priceKey]: e.target.value }))} placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'} className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300" /> {tutar ? paraTR(tutar) : '—'}
{kalemler.some((k) => k.estimatedUnitPrice != null) && (

Gri yazılı değerler şartnamede geçen tahmini birim fiyatlardır — teklifiniz değildir.

)}
) : (

{kalemler.length ? 'Henüz bir kısım seçilmedi. Yukarıdaki resmi kısım eşleştirmesini ve seçimini tamamlayın.' : 'Kalem dökümü bulunamadı. Resmi teklif cetvelini İhale Dosyaları bölümünden yükleyin. Kalem eksikliği teklif türünü değiştirmez.'}

)} {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks} vault={vault} refreshVault={()=>setVaultRefresh(n=>n+1)} downloadFile={(file)=>downloadDocument(file.id,file.ad).catch(e=>setHata(e.message))}/> {kontrolEksikleri.length > 0 &&
{kontrolEksikleri.length} belge/uygunluk kontrolü tamamlanmalı
    {kontrolEksikleri.map((message,i)=>
  • {message}
  • )}
} {/* Toplam */}
Teklif toplamı (KDV hariç) {paraTR(toplam)} TL
{toplam > 0 && (

Yazı ile: {yaziylaTL(toplam)}

)}
{hata && (

{hata}

)} {(kalemEksik || turCeliskisi || sureEksik) &&

{kalemEksik && 'Seçilen her kalemde ad, miktar, birim ve fiyat gerekli. '} {turCeliskisi && 'İlan birim fiyat teklif belirtiyor; götürü bedel seçilemez. '} {sureEksik && 'Teklif geçerlilik süresini kaynak dokümana göre doğrulayın. '}

} {/* Belgeler */}
Hazırlanacak belgeler
{[ { t: 'mektup', ad: 'Teklif Mektubu', ikon: FileSignature, aciklama: 'Rakam ve yazı ile bedel' }, { t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${lotSelection.rows.length} kalem` }, { t: 'excel', ad: 'Excel Teklif Cetveli', ikon: Table2, aciklama: `${lotSelection.rows.length} kalem · XLSX` }, { t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${documents.length} belge` }, ].map((b) => ( ))}
{!hazir && (

{eksikler.length > 0 ? 'Yukarıdaki zorunlu alanları doldurun.' : 'Tutar, kaynak, belge ve ürün uygunluğu kontrollerini tamamlayın.'}

)}

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.

)}
); };