import { bidFirmFields as ALANLAR, bidFirmIssues } from '../services/bidFirmFields'; import { preparationAnalysisSource, preparationConditionSections, conditionText, analysisStatusBlocksPreparation } from '../services/preparationAnalysis'; import { preparationDeadlineProblem } from '../services/preparationDeadline'; import { bidValidityForForm, bidTypeForForm } from '../services/bidTerms'; import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook'; import { getToken } from '../services/auth'; import { getAllUserData, listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account'; import { resolveCurrentTender } from '../services/tenderState'; import { tenderDateKey } from '../services/tenderCalendar'; import React, { useState, useMemo, useEffect, useRef, useId } from 'react'; import { bidRowTotal, validateBidRows, bidTotal, bidAmount } 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 FIRMA_ANAHTAR = 'ihaleist_firma_bilgi'; const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, ''])); export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus }) => { const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp(); const tender = resolveCurrentTender(selectedTender,tenders); 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 fieldPrefix = useId(); const preparationTargets = useRef({}); const targetRef = key => node => { preparationTargets.current[key] = node; }; const goToMissing = key => { const target = preparationTargets.current[key]; if (!target) return; target.scrollIntoView({block:'center'}); target.focus({preventScroll:true}); }; useEffect(()=>{ if(!initialFocus)return; const frame=requestAnimationFrame(()=>goToMissing(initialFocus)); return ()=>cancelAnimationFrame(frame); },[initialFocus]); 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 effectiveResults = results || storedDraft.analysisSource || {}; const kaynakTeklifTuru = bidTypeForForm(tender?.aciklama,effectiveResults.procurementTerms?.bidType); const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || kaynakTeklifTuru.type || ''); const kaynakGecerlilik = bidValidityForForm(tender?.aciklama, effectiveResults.procurementTerms?.bidValidity); const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik.days || ''); const [kisim, setKisim] = useState(storedDraft.kisim || ''); const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || ''); 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(() => bidTotal(satirlar), [satirlar]); const latestDraft = useRef(''); latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,userProfile.scrapedCapabilities?.healthPortfolio]); 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:preparationAnalysisSource(effectiveResults)}}); if (!mounted.current) return; if (saved) { setDirty(latestDraft.current !== submitted); setHata(null); } else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; const toplam = teklifTuru === 'goturu' ? bidAmount(goturuBedel) : kalemToplami; const eksikler = bidFirmIssues(firma); const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0; const turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type); const sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days); const [deadlineNow,setDeadlineNow] = useState(() => new Date()); useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]); const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow); const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn}); const analizEksik = analysisStatusBlocksPreparation(effectiveResults.sourceReview?.status); const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection; const missingActions = [ ...(deadlineProblem ? [{target:'source',text:deadlineProblem}] : []), ...(kaynakKontrolGerekli ? [{target:'source',text:'Resmi dokümanla kaynak karşılaştırmasını tamamlayın.'}] : []), ...(analizEksik ? [{target:'analysis',text:'Kısmi analizi tam ve doğrulanabilir kaynakla tamamlayın.'}] : []), ...(sourceOptions.needsSelection ? [{target:'itemSource',text:'Resmi cetvele uygun kalem kaynağını seçin.'}] : []), ...eksikler.map(a=>({target:`firm:${a.k}`,text:a.message})), ...(turCeliskisi ? [{target:'bidType',text:'Teklif türünü kaynak dokümana göre doğrulayın.'}] : []), ...(sureEksik ? [{target:'validity',text:'Teklif geçerlilik süresini doğrulayın.'}] : []), ...lotSelection.errors.map(text=>({target:'lots',text})), ...(teklifTuru === 'birim' ? (satirlar.length ? satirlar.flatMap((row,i)=> validateBidRows([row]).length ? [{target:`price:${row.priceKey}`,text:`${i+1}. kalem (${row.name || 'Adı eksik'}): ad, miktar, birim ve fiyatı kontrol edin.`}] : []) : [{target:'pricing',text:'Resmi teklif cetvelindeki kalemleri yükleyin veya kısım seçimini tamamlayın.'}]) : []), ...(teklifTuru === 'goturu' && !(toplam > 0) ? [{target:'lumpSum',text:'Toplam teklif bedeli geçersiz veya hesaplama sınırını aşıyor. Pozitif ve kuruşa yuvarlanabilir bir tutar girin.'}] : []), ...(teklifTuru === 'birim' && !kalemEksik && toplam === null ? [{target:'pricing',text:'Genel toplam geçersiz veya güvenli hesaplama sınırını aşıyor. Miktarları ve fiyatları kontrol edin.'}] : []), ...kontrolEksikleri.map(text=>({target:'checks',text})), ]; 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 { const initialDeadlineProblem=preparationDeadlineProblem(tender); if(initialDeadlineProblem)throw new Error(initialDeadlineProblem); 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…

', undefined, {pending:true}); setPrinting(true); const linkedPortfolio=lotSelection.rows.some(row=>checks.products?.[row.priceKey]?.portfolioId); const readPortfolio=async()=>linkedPortfolio ? (await getAllUserData()).firma_profil?.scrapedCapabilities?.healthPortfolio || [] : []; const [currentFiles,currentPortfolio] = await Promise.all([listDocuments(),readPortfolio()]); const reviewedText = effectiveResults.sourceReview?.humanReview; if (reviewedText) await validateDocumentTextReview(reviewedText,tender.ikn); 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 = {healthPortfolio:currentPortfolio,loading:false,error:null,documents:currentFiles.belgeler || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn}; setVault(currentVault); const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault); if (fileErrors.length) throw new Error(fileErrors.join(' ')); const currentDeadlineProblem=preparationDeadlineProblem(tender); if(currentDeadlineProblem)throw new Error(currentDeadlineProblem); const f = formVerisi(); if (tip === 'excel') { const buffer = await createBidWorkbook(f,satirlar); if (reviewedText) await validateDocumentTextReview(reviewedText,tender.ikn); const [finalFiles,finalPortfolio]=await Promise.all([listDocuments(),readPortfolio()]); if (!mounted.current || session !== getToken() || submitted !== latestDraft.current) throw new Error('Form veya oturum değişti; Excel çıktısını yeniden oluşturun.'); const finalVault={...currentVault,documents:finalFiles.belgeler || [],healthPortfolio:finalPortfolio}; setVault(finalVault); const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault); if(finalErrors.length)throw new Error(finalErrors.join(' ')); const finalDeadlineProblem=preparationDeadlineProblem(tender); if(finalDeadlineProblem)throw new Error(finalDeadlineProblem); 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 (
0 || printing} className="bg-white rounded-2xl border-2 border-emerald-300 shadow-xs overflow-hidden"> {acik && (
setDirty(true)}>
{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}
{missingActions.length > 0 &&
Çıktı öncesi tamamlanacaklar

İlgili alana gitmek için eksikliğe tıklayın. Taslağı eksikleriyle kaydedebilirsiniz.

    {missingActions.map((item,i)=>
  • )}
}
{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.

}
{preparationConditionSections.some(([field]) => effectiveResults[field]?.length > 0) &&
Yeterlik, risk ve diğer kaynak koşulları

Analiz bulgularını resmi maddelerle karşılaştırın. Bu liste bütün koşulların bulunduğunu veya karşılandığını kanıtlamaz.

{preparationConditionSections.filter(([field]) => effectiveResults[field]?.length > 0).map(([field,label]) =>
{label}
    {effectiveResults[field].map((row,index)=>
  • {conditionText(row)}
  • )}
)}
} {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.

}
} {kaynakTeklifTuru.evidence.length > 0 &&

Teklif türünün kaynağı

{kaynakTeklifTuru.status === 'conflict' &&

Kaynaklarda farklı teklif türleri var. Güncel resmi dokümanı ve karma kapsamı kontrol edin.

} {[...new Set(kaynakTeklifTuru.evidence.map(e=>e.quote))].map(quote=>
{quote}
)}
} {kaynakGecerlilik.evidence.length > 0 &&

Teklif geçerlilik süresinin kaynağı

{kaynakGecerlilik.status === 'conflict' &&

Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.

} {[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=>
{quote}
)}
} {effectiveResults.sourceReview?.humanReview &&

Metin incelemesi: sürüm {effectiveResults.sourceReview.humanReview.revision} · {effectiveResults.sourceReview.humanReview.reviewedBy} · {effectiveResults.sourceReview.humanReview.reviewedAt}. Kullanıcı karşılaştırmasıdır; resmi onay değildir.

} {deadlineProblem &&

{deadlineProblem}

} {analizEksik &&

Analiz tamamlanmadı ({effectiveResults.sourceReview?.status || 'durum bilinmiyor'}): kaynak, OCR veya kalem bilgisi kontrol gerektiriyor. {(effectiveResults.sourceReview?.issues || []).join(' ')} Tam ve doğrulanabilir kaynakla 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 issue = eksikler.find(field=>field.k===a.k); const eksik = Boolean(issue); 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); const invalidRow = validateBidRows([r]).length > 0; const errorId = `${fieldPrefix}-price-error-${i}`; return ( ); })}
Kalem Miktar Birim fiyat Tutar
{r.name || '—'} {invalidRow &&

{!(miktar > 0) || !birim || !r.name?.trim() ? 'Kalem adı, miktarı ve birimi için resmi cetveli kontrol edin; kaynağı düzeltip analizi yenileyin.' : 'Geçerli, sıfırdan büyük birim fiyat girin.'}

}
{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={value => {setChecks(value);setDirty(true);}} vault={{...vault,tenderIkn:tender.ikn}} 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ç) {toplam === null ? 'Hesaplanamadı — fiyatları kontrol edin' : `${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 && 'Teklif türünü resmi dokümana göre seçin; kaynakla farklılık varsa giderin. '} {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.

)}
); };