578 lines
37 KiB
React
578 lines
37 KiB
React
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ü', '<p role="status">Belgeler kontrol ediliyor…</p>', 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 (
|
||
<div role="region" aria-label="İhale dosyası hazırlama" aria-busy={savingCount > 0 || printing} 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" onChange={() => setDirty(true)}>
|
||
<div className="flex items-center gap-3">
|
||
<button type="button" onClick={saveDraft} disabled={savingCount > 0}
|
||
className="px-4 py-2 rounded-lg bg-blue-700 text-white text-xs disabled:opacity-50">Hazırlık taslağını kaydet</button>
|
||
<span role="status" className="text-xs">{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}</span>
|
||
</div>
|
||
|
||
{missingActions.length > 0 && <section aria-label="Hazırlık eksikleri" className="rounded-xl border border-amber-300 bg-amber-50 p-3">
|
||
<h5 className="text-sm font-bold">Çıktı öncesi tamamlanacaklar</h5>
|
||
<p className="text-xs mt-1">İlgili alana gitmek için eksikliğe tıklayın. Taslağı eksikleriyle kaydedebilirsiniz.</p>
|
||
<ul className="text-xs mt-2 space-y-2 max-h-64 overflow-auto">
|
||
{missingActions.map((item,i)=><li key={`${item.target}:${i}`}>
|
||
<button type="button" onClick={()=>goToMissing(item.target)} className="text-left text-blue-800 underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-700">{item.text}</button>
|
||
</li>)}
|
||
</ul>
|
||
</section>}
|
||
|
||
<div ref={targetRef('source')} tabIndex={-1} className="rounded-xl border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||
{kaynakKontrolGerekli && <p role="alert" className="text-xs text-amber-900">
|
||
{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.
|
||
</p>}
|
||
{eskiFiyatlar && <p className="text-xs text-amber-900">Eski taslak fiyatları satır sırasına göre kaydedilmiş. Yanlış ürüne aktarılmaması için fiyatları yeniden girin.</p>}
|
||
<label className="flex items-start gap-2 text-xs text-slate-800">
|
||
<input type="checkbox" checked={!kaynakKontrolGerekli}
|
||
onChange={e => setReviewedSource(e.target.checked ? sourceSnapshot : null)} />
|
||
Bu sürümün kalemlerini, miktarlarını, teklif türünü, geçerlilik süresini ve aşağıdaki yeterlik/risk koşullarını resmi dokümanla karşılaştırdım.
|
||
</label>
|
||
</div>
|
||
|
||
{preparationConditionSections.some(([field]) => effectiveResults[field]?.length > 0) &&
|
||
<details className="rounded-xl border border-amber-300 p-3" open>
|
||
<summary className="font-bold text-sm cursor-pointer">Yeterlik, risk ve diğer kaynak koşulları</summary>
|
||
<p className="text-xs mt-2">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.</p>
|
||
{preparationConditionSections.filter(([field]) => effectiveResults[field]?.length > 0).map(([field,label]) =>
|
||
<section key={field} className="mt-3 text-xs"><h5 className="font-bold">{label}</h5>
|
||
<ul className="list-disc pl-5 space-y-1">{effectiveResults[field].map((row,index)=><li key={index}>{conditionText(row)}</li>)}</ul>
|
||
</section>)}
|
||
</details>}
|
||
|
||
{sourceOptions.conflict && <fieldset className="rounded-xl border border-amber-400 bg-amber-50 p-3 space-y-2">
|
||
<legend className="text-sm font-bold">Kalem kaynakları farklı</legend>
|
||
<p className="text-xs">İ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.</p>
|
||
<label className="block text-xs">Teklifte kullanılacak kalem listesi
|
||
<select ref={targetRef('itemSource')} value={sourceOptions.selected} onChange={e => {
|
||
setItemSource({source:e.target.value,signature:sourceOptions.signature});
|
||
setReviewedSource(null); setDirty(true);
|
||
}} className="block mt-1 rounded border p-2 w-full">
|
||
<option value="">Resmi cetveli kontrol ederek seçin</option>
|
||
<option value="tender">İhale havuzu — {sourceOptions.tenderCount} kalem</option>
|
||
<option value="analysis">Doküman analizi — {sourceOptions.analysisCount} kalem</option>
|
||
</select>
|
||
</label>
|
||
{sourceOptions.needsSelection && <p role="alert" className="text-xs text-red-700">Kaynak seçilmeden çıktı hazırlanamaz. Kaynak listeleri değiştiğinde seçim yeniden yapılmalıdır.</p>}
|
||
</fieldset>}
|
||
{kaynakTeklifTuru.evidence.length > 0 && <div className="text-xs border rounded-lg p-3 space-y-1">
|
||
<p className="font-bold">Teklif türünün kaynağı</p>
|
||
{kaynakTeklifTuru.status === 'conflict' && <p role="alert" className="text-red-700">Kaynaklarda farklı teklif türleri var. Güncel resmi dokümanı ve karma kapsamı kontrol edin.</p>}
|
||
{[...new Set(kaynakTeklifTuru.evidence.map(e=>e.quote))].map(quote=><blockquote key={quote}>{quote}</blockquote>)}
|
||
</div>}
|
||
{kaynakGecerlilik.evidence.length > 0 && <div className="text-xs border rounded-lg p-3 space-y-1">
|
||
<p className="font-bold">Teklif geçerlilik süresinin kaynağı</p>
|
||
{kaynakGecerlilik.status === 'conflict' && <p role="alert" className="text-red-700">Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.</p>}
|
||
{[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=><blockquote key={quote}>{quote}</blockquote>)}
|
||
</div>}
|
||
{effectiveResults.sourceReview?.humanReview && <p className="text-xs text-slate-600">
|
||
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.
|
||
</p>}
|
||
{deadlineProblem && <p role="alert" className="text-xs text-red-700">{deadlineProblem}</p>}
|
||
{analizEksik && <p ref={targetRef('analysis')} tabIndex={-1} role="alert" className="text-xs text-red-700">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.</p>}
|
||
{/* 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 issue = eksikler.find(field=>field.k===a.k);
|
||
const eksik = Boolean(issue);
|
||
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
|
||
ref={targetRef(`firm:${a.k}`)}
|
||
type={a.k==='eposta' ? 'email' : a.k==='telefon' ? 'tel' : 'text'}
|
||
aria-invalid={eksik}
|
||
aria-describedby={eksik ? `${fieldPrefix}-firm-error-${a.k}` : undefined}
|
||
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'
|
||
}`}
|
||
/>
|
||
{issue&&<p id={`${fieldPrefix}-firm-error-${a.k}`} className="text-xs text-amber-800 mt-1">{issue.message}</p>}
|
||
</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
|
||
ref={targetRef('bidType')}
|
||
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="">Resmi dokümana göre seçin</option>
|
||
<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
|
||
ref={targetRef('validity')}
|
||
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>
|
||
|
||
<section ref={targetRef('lots')} tabIndex={-1} className="border rounded-xl p-3 space-y-2" aria-label="Kısmi teklif seçimi">
|
||
<label className="flex gap-2 text-xs font-bold"><input type="checkbox" checked={partialBid.enabled}
|
||
onChange={e=>setPartialBid(p=>({...p,enabled:e.target.checked}))}/>Yalnız seçtiğim resmi kısımlara teklif hazırlıyorum</label>
|
||
{partialBid.enabled && <>
|
||
<label className="block text-xs">Kısmi teklif izni — resmi doküman ve madde
|
||
<input className="w-full border rounded p-2" value={partialBid.source || ''} onChange={e=>setPartialBid(p=>({...p,source:e.target.value}))}/>
|
||
</label>
|
||
<p className="text-xs text-slate-600">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.</p>
|
||
<div className="flex flex-wrap gap-3">{allLots.map(lot=><label key={lot} className="text-xs flex gap-1">
|
||
<input type="checkbox" checked={(partialBid.selected || []).includes(lot)} onChange={e=>setPartialBid(p=>({...p,selected:e.target.checked ? [...(p.selected || []),lot] : (p.selected || []).filter(l=>l!==lot)}))}/>
|
||
Kısım {lot}
|
||
</label>)}</div>
|
||
<div className="max-h-64 overflow-auto space-y-2">{kalemler.map(row=><label key={row.priceKey} className="flex items-center gap-2 text-xs">
|
||
<span className="flex-1">{row.name}</span><span>Resmi kısım</span>
|
||
<input aria-label={`${row.name} resmi kısım`} className="border rounded p-1 w-24" value={rowLot(row,partialBid.assignments)} readOnly={row.kisim != null || row.lot != null}
|
||
onChange={e=>setPartialBid(p=>({...p,assignments:{...p.assignments,[row.priceKey]:e.target.value}}))}/>
|
||
</label>)}</div>
|
||
{lotSelection.errors.map(message=><p key={message} role="alert" className="text-xs text-red-700">{message}</p>)}
|
||
</>}
|
||
</section>
|
||
|
||
<section ref={targetRef('pricing')} tabIndex={-1} aria-label="Teklif fiyatlandırma">
|
||
{/* 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
|
||
ref={targetRef('lumpSum')}
|
||
value={goturuBedel}
|
||
onChange={(e) => setGoturuBedel(e.target.value)}
|
||
placeholder="0"
|
||
className="w-full sm:w-64 text-sm font-bold px-3 py-2 rounded-lg border border-slate-300"
|
||
/>
|
||
</label>
|
||
) : lotSelection.rows.length > 0 ? (
|
||
<div className="space-y-2">
|
||
<h5 className="font-extrabold text-xs text-slate-900">Kalem fiyatlandırma</h5>
|
||
<p className="text-xs text-slate-500">Ondalık fiyatı 125,50 veya 125.50 biçiminde yazın; binlik ayırıcı kullanmayın.</p>
|
||
<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 = bidRowTotal(r);
|
||
const invalidRow = validateBidRows([r]).length > 0;
|
||
const errorId = `${fieldPrefix}-price-error-${i}`;
|
||
return (
|
||
<tr key={r.priceKey} className="border-b border-slate-100">
|
||
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}
|
||
{invalidRow && <p id={errorId} className="text-red-700 mt-1">{!(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.'}</p>}
|
||
</td>
|
||
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
|
||
{miktar ?? '—'} {birim}
|
||
</td>
|
||
<td className="py-1.5 text-right">
|
||
<input
|
||
ref={targetRef(`price:${r.priceKey}`)}
|
||
aria-label={`${i+1}. kalem ${r.name || 'Adı eksik'} birim fiyat (TL)`}
|
||
aria-invalid={invalidRow || undefined}
|
||
aria-describedby={invalidRow ? errorId : undefined}
|
||
value={fiyatlar[r.priceKey] ?? ''}
|
||
onChange={(e) =>
|
||
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"
|
||
/>
|
||
</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">
|
||
{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.'}
|
||
</p>
|
||
)}
|
||
|
||
</section>
|
||
<section ref={targetRef('checks')} tabIndex={-1} aria-label="Belge ve ürün uygunluğu kontrolleri">
|
||
<PreparationChecks rows={lotSelection.rows} documents={documents} manualDocuments={manualDocuments}
|
||
setManualDocuments={value => {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 && <details className="bg-amber-50 border border-amber-300 rounded-xl p-3">
|
||
<summary className="text-xs font-bold cursor-pointer">{kontrolEksikleri.length} belge/uygunluk kontrolü tamamlanmalı</summary>
|
||
<ul className="text-xs list-disc pl-5 mt-2 space-y-1">{kontrolEksikleri.map((message,i)=><li key={i}>{message}</li>)}</ul>
|
||
</details>}
|
||
|
||
</section>
|
||
|
||
{/* 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">{toplam === null ? 'Hesaplanamadı — fiyatları kontrol edin' : `${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>
|
||
)}
|
||
|
||
{(kalemEksik || turCeliskisi || sureEksik) && <p role="alert" className="text-xs bg-amber-50 p-3 text-amber-900">
|
||
{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. '}
|
||
</p>}
|
||
{/* 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: `${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) => (
|
||
<button
|
||
key={b.t}
|
||
type="button"
|
||
disabled={!hazir || printing || ((b.t === 'excel' || b.t === 'cetvel') && teklifTuru !== 'birim')}
|
||
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" /> {b.t === 'excel' ? 'Excel indir' : 'Yazdır / PDF'}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{!hazir && (
|
||
<p className="text-[11px] text-slate-500">
|
||
{eksikler.length > 0
|
||
? 'Yukarıdaki zorunlu alanları doldurun.'
|
||
: 'Tutar, kaynak, belge ve ürün uygunluğu kontrollerini tamamlayın.'}
|
||
</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>
|
||
);
|
||
};
|