feat: prepare bids by official lot selection
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation';
|
||||
import { sourceRows, bidSourceSnapshot, restoreBidPrices } from '../services/bidSource';
|
||||
import { rowLot, selectBidLots } from '../services/bidLots';
|
||||
import { PreparationChecks } from './PreparationChecks';
|
||||
import { preparationBlockers, documentKey } from '../services/preparationChecks';
|
||||
import { useApp } from '../context/AppContext';
|
||||
@@ -66,8 +67,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
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);
|
||||
const sourceSnapshot = bidSourceSnapshot(tender, {...effectiveResults, documentChecklist:documents}, kalemler);
|
||||
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]);
|
||||
const [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null);
|
||||
const [fiyatlar, setFiyatlar] = useState(() => restoreBidPrices(storedDraft));
|
||||
const kaynakKontrolGerekli = reviewedSource !== sourceSnapshot;
|
||||
@@ -80,13 +84,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
};
|
||||
|
||||
const satirlar = useMemo(
|
||||
() => kalemler.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })),
|
||||
[kalemler, fiyatlar],
|
||||
() => 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 saveDraft = async () => {
|
||||
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null}}});
|
||||
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null}}});
|
||||
if (saved) { setDirty(false); setHata(null); }
|
||||
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
||||
};
|
||||
@@ -97,14 +101,14 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
||||
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
|
||||
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
|
||||
const kontrolEksikleri = preparationBlockers(kalemler, documents, checks);
|
||||
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0;
|
||||
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks);
|
||||
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0;
|
||||
|
||||
const formVerisi = () => ({
|
||||
...firma,
|
||||
teklifTuru,
|
||||
gecerlilikGun,
|
||||
kisim,
|
||||
kisim: partialBid.enabled ? partialBid.selected.join(', ') : kisim,
|
||||
idare: tender?.kurum || '',
|
||||
isinAdi: tender?.baslik || '',
|
||||
ikn: tender?.ikn || '',
|
||||
@@ -238,6 +242,27 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
</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">
|
||||
@@ -251,7 +276,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
className="w-full sm:w-64 text-sm font-bold px-3 py-2 rounded-lg border border-slate-300"
|
||||
/>
|
||||
</label>
|
||||
) : kalemler.length > 0 ? (
|
||||
) : 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>
|
||||
@@ -302,11 +327,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg p-3">
|
||||
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.
|
||||
{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={kalemler} documents={documents} manualDocuments={manualDocuments}
|
||||
<PreparationChecks rows={lotSelection.rows} documents={documents} manualDocuments={manualDocuments}
|
||||
setManualDocuments={value => {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks}/>
|
||||
{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>
|
||||
@@ -342,7 +367,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
<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: `${kalemler.length} kalem` },
|
||||
{ t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${lotSelection.rows.length} kalem` },
|
||||
{ t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${documents.length} belge` },
|
||||
].map((b) => (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const rowLot = (row, assignments = {}) => String(row.kisim ?? row.lot ?? assignments[row.priceKey] ?? '').trim();
|
||||
export function selectBidLots(rows, partial = {}) {
|
||||
if (!partial.enabled) return {rows, errors:[],lots:[]};
|
||||
const lots = [...new Set(rows.map(r=>rowLot(r,partial.assignments)).filter(Boolean))];
|
||||
const selected = new Set(partial.selected || []);
|
||||
const errors = [];
|
||||
if (!partial.source?.trim()) errors.push('Kısmi teklif izninin resmi doküman ve madde kaynağını yazın.');
|
||||
if (rows.some(r=>!rowLot(r,partial.assignments))) errors.push('Kısmi teklif için her kalemin resmi kısım numarası belirlenmeli.');
|
||||
if (!lots.some(l=>selected.has(l))) errors.push('En az bir resmi kısım seçin.');
|
||||
if ([...selected].some(l=>!lots.includes(l))) errors.push('Seçili bir kısım güncel kalemlerde bulunamadı; seçimi yeniden kontrol edin.');
|
||||
return {rows:rows.filter(r=>selected.has(rowLot(r,partial.assignments))),errors,lots};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function sourceRows(tender, results) {
|
||||
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
|
||||
const occurrences = new Map();
|
||||
return items.map(k => {
|
||||
const identity = JSON.stringify([k.id ?? k.no ?? null, k.kisim ?? null, k.name, k.quantity, k.keySpecs || []]);
|
||||
const identity = JSON.stringify([k.id ?? k.no ?? null, k.kisim ?? k.lot ?? null, k.name, k.quantity, k.keySpecs || []]);
|
||||
const occurrence = occurrences.get(identity) || 0;
|
||||
occurrences.set(identity, occurrence + 1);
|
||||
return { ...k, priceKey: JSON.stringify([identity, occurrence]) };
|
||||
|
||||
Reference in New Issue
Block a user