513 lines
32 KiB
React
513 lines
32 KiB
React
import { preparationAnalysisSource, preparationConditionSections, conditionText } 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 { listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account';
|
||
import { tenderDateKey } from '../services/tenderCalendar';
|
||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||
import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation';
|
||
import { sourceRows, bidSourceSnapshot, restoreBidPrices, bidSourceOptions } from '../services/bidSource';
|
||
import { rowLot, selectBidLots } from '../services/bidLots';
|
||
import { PreparationChecks } from './PreparationChecks';
|
||
import { preparationBlockers, documentKey } from '../services/preparationChecks';
|
||
import { useApp } from '../context/AppContext';
|
||
import {
|
||
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
|
||
AlertTriangle, Printer, Info,
|
||
} from 'lucide-react';
|
||
import {
|
||
yaziylaTL, paraTR, miktarAyristir, belgeYazdir,
|
||
teklifMektubuHtml, teklifCetveliHtml, evrakListesiHtml,
|
||
} from '../services/bidDocs';
|
||
|
||
/**
|
||
* İhale Dosyası Hazırla — derin analiz bittikten sonra teklif zarfına girecek
|
||
* belgeleri üretir. Analizden gelen kalemler fiyatlandırılır, eksik firma
|
||
* bilgileri kullanıcıya sorulur; belgeler tam Türkçe olarak yazdırılır.
|
||
*
|
||
* Firma bilgileri localStorage'da tutulur — her ihalede yeniden sorulmasın.
|
||
*/
|
||
|
||
const ALANLAR = [
|
||
{ k: 'firmaUnvan', l: 'Firma ticaret unvanı', zorunlu: true, genis: true },
|
||
{ k: 'adres', l: 'Tebligata esas adres', zorunlu: true, genis: true },
|
||
{ k: 'vergiDairesi', l: 'Vergi dairesi', zorunlu: true },
|
||
{ k: 'vergiNo', l: 'Vergi numarası', zorunlu: true },
|
||
{ k: 'sicilNo', l: 'Ticaret sicil / MERSİS no', zorunlu: false },
|
||
{ k: 'yetkiliAd', l: 'Yetkili ad soyad', zorunlu: true },
|
||
{ k: 'yetkiliGorev', l: 'Yetkilinin görevi', zorunlu: true, ph: 'Örn. Genel Müdür' },
|
||
{ k: 'telefon', l: 'Telefon', zorunlu: true },
|
||
{ k: 'eposta', l: 'E-posta', zorunlu: true },
|
||
];
|
||
|
||
const FIRMA_ANAHTAR = 'ihaleist_firma_bilgi';
|
||
|
||
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
|
||
|
||
export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||
const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
|
||
const tender = {...selectedTender, ...tenders.find(t => t.id === selectedTender?.id)};
|
||
const storedDraft = tender?.bidDraft || {};
|
||
const [vault,setVault] = useState({loading:true,error:null,documents:[]});
|
||
const [vaultRefresh,setVaultRefresh] = useState(0);
|
||
useEffect(() => {
|
||
let cancelled=false;
|
||
setVault({loading:true,error:null,documents:[]});
|
||
listDocuments().then(data=>{if(!cancelled)setVault({loading:false,error:null,documents:data.belgeler || []});})
|
||
.catch(error=>{if(!cancelled)setVault({loading:false,error:error.message,documents:[]});});
|
||
return ()=>{cancelled=true;};
|
||
},[authUser?.email,vaultRefresh]);
|
||
const [dirty, setDirty] = useState(false);
|
||
const [acik, setAcik] = useState(true);
|
||
const [hata, setHata] = useState(null);
|
||
|
||
const [firma, setFirma] = useState(() => {
|
||
let kayitli = {};
|
||
try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR + ':' + authUser?.email) || '{}'); } catch { /* yoksay */ }
|
||
return {
|
||
...bosFirma(),
|
||
firmaUnvan: userProfile?.company || '',
|
||
...kayitli,
|
||
...(storedDraft.firma || {}),
|
||
};
|
||
});
|
||
|
||
const 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(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]);
|
||
const latestDraft = useRef('');
|
||
latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]);
|
||
const mounted = useRef(true);
|
||
const printWindow = useRef(null);
|
||
const [printing,setPrinting] = useState(false);
|
||
useEffect(()=>{mounted.current=true;return ()=>{mounted.current=false;printWindow.current?.close();};},[]);
|
||
const saveDraft = async () => {
|
||
const submitted = latestDraft.current;
|
||
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,analysisSource:preparationAnalysisSource(effectiveResults)}});
|
||
if (!mounted.current) return;
|
||
if (saved) { setDirty(latestDraft.current !== submitted); setHata(null); }
|
||
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
||
};
|
||
|
||
const toplam = teklifTuru === 'goturu' ? (parseBidPrice(goturuBedel) || 0) : kalemToplami;
|
||
|
||
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
|
||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
||
const turCeliskisi = !['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,deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
|
||
const analizEksik = effectiveResults.sourceReview?.status === 'partial';
|
||
const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection;
|
||
|
||
const formVerisi = () => ({
|
||
...firma,
|
||
teklifTuru,
|
||
gecerlilikGun,
|
||
kisim: partialBid.enabled ? partialBid.selected.join(', ') : kisim,
|
||
idare: tender?.kurum || '',
|
||
isinAdi: tender?.baslik || '',
|
||
ikn: tender?.ikn || '',
|
||
ihaleTarihi: tender?.ihaleTarihi || tender?.tarih || '',
|
||
});
|
||
|
||
const uret = async (tip) => {
|
||
if (printWindow.current) return;
|
||
const submitted = latestDraft.current;
|
||
const session = getToken();
|
||
setHata(null);
|
||
try {
|
||
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>Belgeler kontrol ediliyor…</p>');
|
||
setPrinting(true);
|
||
const currentFiles = await listDocuments();
|
||
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 = {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);
|
||
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 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 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>
|
||
|
||
<div 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 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 role="alert" className="text-xs text-red-700">Analiz kısmi: 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 eksik = a.zorunlu && !String(firma[a.k] || '').trim();
|
||
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
|
||
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'
|
||
}`}
|
||
/>
|
||
</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
|
||
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
|
||
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 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>
|
||
|
||
{/* 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
|
||
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);
|
||
return (
|
||
<tr key={r.priceKey} className="border-b border-slate-100">
|
||
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
|
||
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
|
||
{miktar ?? '—'} {birim}
|
||
</td>
|
||
<td className="py-1.5 text-right">
|
||
<input
|
||
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>
|
||
)}
|
||
|
||
<PreparationChecks rows={lotSelection.rows} documents={documents} manualDocuments={manualDocuments}
|
||
setManualDocuments={value => {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks} 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>}
|
||
|
||
{/* 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">{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>
|
||
);
|
||
};
|