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 (İlgili alana gitmek için eksikliğe tıklayın. Taslağı eksikleriyle kaydedebilirsiniz.
{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.
}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]) =>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})}
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})}
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.
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.
{message}
)} >}Ondalık fiyatı 125,50 veya 125.50 biçiminde yazın; binlik ayırıcı kullanmayın.
| 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.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.'}
)}Yazı ile: {yaziylaTL(toplam)}
)}{hata}
{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 */}{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.