Files
ihaleist/src/components/BidPackageBuilder.jsx
T

578 lines
37 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};