fix: preserve bid drafts and align tender source data

This commit is contained in:
2026-09-06 08:40:10 +03:00
parent c91631d99f
commit 456728b4bd
26 changed files with 510 additions and 81 deletions
+2 -2
View File
@@ -308,7 +308,7 @@ export const AiSpecAnalyzerModal = () => {
</h4>
<ul className="space-y-2 text-xs text-slate-700">
{(results.qualificationCriteria || []).length === 0 && (
<li className="text-slate-400">Belgede yeterlik kriteri tespit edilemedi.</li>
<li className="text-slate-400">Bu bölüm için yapılandırılmış yeterlik listesi çıkarılamadı. Derin analizdeki belge ve şartları kontrol edin.</li>
)}
{(results.qualificationCriteria || []).map((item, idx) => (
<li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium">
@@ -338,7 +338,7 @@ export const AiSpecAnalyzerModal = () => {
</h4>
<div className="space-y-2 text-xs">
{(results.risksAndPenalties || []).length === 0 && (
<p className="text-slate-400">Belgede cezai hüküm/risk tespit edilemedi.</p>
<p className="text-slate-400">Bu bölümde yapılandırılmış ceza/risk listesi yok. Bu, belgede risk bulunmadığı anlamına gelmez.</p>
)}
{(results.risksAndPenalties || []).map((risk, idx) => (
<div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1">
+26 -21
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation';
import { useApp } from '../context/AppContext';
import {
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
@@ -34,35 +35,38 @@ const FIRMA_ANAHTAR = 'ihaleist_firma_bilgi';
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender }) => {
const { userProfile } = useApp();
const { userProfile, mutateTender, savingCount, authUser } = useApp();
const storedDraft = tender?.bidDraft || {};
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) || '{}'); } catch { /* yoksay */ }
try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR + ':' + authUser?.email) || '{}'); } catch { /* yoksay */ }
return {
...bosFirma(),
firmaUnvan: userProfile?.company || '',
...kayitli,
...(storedDraft.firma || {}),
};
});
const [teklifTuru, setTeklifTuru] = useState('birim');
const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim');
const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || '');
const [gecerlilikGun, setGecerlilikGun] = useState(kaynakGecerlilik?.[1] || '');
const [kisim, setKisim] = useState('');
const [goturuBedel, setGoturuBedel] = useState('');
const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik?.[1] || '');
const [kisim, setKisim] = useState(storedDraft.kisim || '');
const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || '');
const kalemler = tender?.items?.length
? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]}))
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
const [fiyatlar, setFiyatlar] = useState({});
const [fiyatlar, setFiyatlar] = useState(storedDraft.fiyatlar || {});
const alanYaz = (k, v) => {
const yeni = { ...firma, [k]: v };
setFirma(yeni);
try { localStorage.setItem(FIRMA_ANAHTAR, JSON.stringify(yeni)); } catch { /* kota */ }
try { localStorage.setItem(FIRMA_ANAHTAR + ':' + authUser?.email, JSON.stringify(yeni)); } catch { /* kota */ }
};
const satirlar = useMemo(
@@ -70,21 +74,17 @@ export const BidPackageBuilder = ({ results, tender }) => {
[kalemler, fiyatlar],
);
const kalemToplami = useMemo(
() => satirlar.reduce((t, r) => {
const { miktar } = miktarAyristir(r.quantity);
return t + (miktar ?? 0) * (Number(r.birimFiyat) || 0);
}, 0),
[satirlar],
);
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,fiyatlar}});
if (saved) { setDirty(false); setHata(null); }
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
};
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
const kalemEksik = teklifTuru === 'birim' && (!satirlar.length || satirlar.some(r => {
const {miktar,birim} = miktarAyristir(r.quantity);
return !r.name || !(miktar > 0) || !birim || !(Number(r.birimFiyat) > 0);
}));
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 hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik;
@@ -135,7 +135,12 @@ export const BidPackageBuilder = ({ results, tender }) => {
</button>
{acik && (
<div className="border-t border-emerald-200 p-5 space-y-5">
<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>
{/* Sizden beklenen bilgiler */}
{eksikler.length > 0 && (
@@ -238,7 +243,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
<tbody>
{satirlar.map((r, i) => {
const { miktar, birim } = miktarAyristir(r.quantity);
const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0);
const tutar = bidRowTotal(r);
return (
<tr key={i} className="border-b border-slate-100">
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
+2 -2
View File
@@ -74,7 +74,7 @@ export const CompanyScannerModal = () => {
<h2 className="font-extrabold text-base flex items-center gap-2">
Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru
</h2>
<p className="text-xs text-blue-200">Sayfalarınızdaki gerçek ürün ve faaliyet kollarını tarar, alakasız harici sektörleri kesinlikle engeller.</p>
<p className="text-xs text-blue-200">Ürün ve faaliyet alanı önerilerini sitenizden çıkarır; profilinize eklemeden önce doğrulayın.</p>
</div>
</div>
<button
@@ -95,7 +95,7 @@ export const CompanyScannerModal = () => {
<span>Sektör Filtresi Aktif: Sadece web sitenizdeki kesinleşmiş faaliyet alanları ve detaylı ürün kataloğu işlenir.</span>
</div>
<span className="bg-blue-600 text-white text-[10px] font-extrabold px-2 py-0.5 rounded-full">
Sıfır Alakasız Sektör Garantisi
Açıklanabilir ürün ve sektör eşleşmesi
</span>
</div>
+22 -8
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useApp } from '../context/AppContext';
import { BidPackageBuilder } from './BidPackageBuilder';
import { TenderDocuments } from './TenderDocuments';
@@ -18,8 +18,20 @@ export const TenderDetailModal = () => {
// Kur Farkı Calculator State
const [bidAmount, setBidAmount] = useState(15000);
const usdRate = 36.45;
const eurRate = 39.80;
const [fx, setFx] = useState(null);
const [fxError, setFxError] = useState(null);
useEffect(() => {
if (!activeTenderDetail) return;
let cancelled = false;
fetch('/api/exchange-rates').then(r => r.json()).then(d => {
if (cancelled) return;
if (!d.success) throw new Error(d.error);
setFx(d); setFxError(d.refreshError || null);
}).catch(e => { if (!cancelled) setFxError(e.message); });
return () => { cancelled = true; };
}, [activeTenderDetail?.id]);
const usdRate = fx?.rates?.USD;
const eurRate = fx?.rates?.EUR;
// Stamp Drag Position State
const [stampPos, setStampPos] = useState({ x: 220, y: 180 });
@@ -320,17 +332,19 @@ export const TenderDetailModal = () => {
<span className="font-bold text-xs uppercase tracking-wider flex items-center gap-1.5">
<Calculator className="w-4 h-4 text-blue-400" /> Kur Farkı Hesaplayıcı
</span>
<span className="text-[10px] bg-blue-500/30 text-blue-300 px-2 py-0.5 rounded font-bold">TCMB Canlı</span>
<span className="text-[10px] bg-blue-500/30 text-blue-300 px-2 py-0.5 rounded font-bold">TCMB döviz satış</span>
</div>
{fx && <p className="text-[11px]"><a href={fx.source} target="_blank" rel="noreferrer" className="underline">TCMB bülteni: {fx.bulletinDate}</a> · Alındı: {new Date(fx.fetchedAt).toLocaleString('tr-TR')}</p>}
{fxError && <p role="alert" className="text-xs text-amber-200">{fxError}</p>}
<div className="grid grid-cols-2 gap-2 text-xs font-semibold">
<div className="bg-white/10 p-2.5 rounded-lg border border-white/10">
<span className="text-slate-400 block text-[10px]">USD / TRY</span>
<span className="text-base font-black text-emerald-400">₺{usdRate}</span>
<span className="text-base font-black text-emerald-400">{usdRate ? `₺${usdRate}` : '—'}</span>
</div>
<div className="bg-white/10 p-2.5 rounded-lg border border-white/10">
<span className="text-slate-400 block text-[10px]">EUR / TRY</span>
<span className="text-base font-black text-blue-400">₺{eurRate}</span>
<span className="text-base font-black text-blue-400">{eurRate ? `₺${eurRate}` : '—'}</span>
</div>
</div>
@@ -347,11 +361,11 @@ export const TenderDetailModal = () => {
<div className="bg-blue-950/80 p-3 rounded-lg border border-blue-800 text-xs space-y-1">
<div className="flex justify-between text-slate-300">
<span>Dolar Karşılığı:</span>
<span className="font-bold text-white">${(bidAmount / usdRate).toFixed(2)}</span>
<span className="font-bold text-white">{usdRate ? '$' + (bidAmount / usdRate).toFixed(2) : 'Kur bekleniyor'}</span>
</div>
<div className="flex justify-between text-slate-300">
<span>Euro Karşılığı:</span>
<span className="font-bold text-white">€{(bidAmount / eurRate).toFixed(2)}</span>
<span className="font-bold text-white">{eurRate ? '€' + (bidAmount / eurRate).toFixed(2) : 'Kur bekleniyor'}</span>
</div>
</div>
</div>
+41 -19
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext';
import {
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
@@ -28,6 +28,14 @@ export const UrunAramaModal = () => {
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
urunAramaBaslangic, setUrunAramaBaslangic,
} = useApp();
const requestVersion = useRef(0);
const [submitted, setSubmitted] = useState(null);
useEffect(() => {
if (!isUrunAramaOpen) {
requestVersion.current++;
setDurum((d) => ({ ...d, yukleniyor: false }));
}
}, [isUrunAramaOpen]);
const [q, setQ] = useState('');
const [teknik, setTeknik] = useState(false);
const [sadeceAcik, setSadeceAcik] = useState(true);
@@ -60,30 +68,40 @@ export const UrunAramaModal = () => {
if (!isUrunAramaOpen) return null;
const araOkas = async (kodlar, sayfa = 0) => {
if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; }
const sorgula = async (query, sayfa = 0) => {
const request = ++requestVersion.current;
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra('', { skip: sayfa * SAYFA, take: SAYFA, okas: kodlar });
const d = await urunAra(query.mod === 'metin' ? query.label : '', {
skip: sayfa * SAYFA, take: SAYFA,
teknik: query.teknik, okas: query.kodlar || [],
});
if (request !== requestVersion.current) return;
setSubmitted(query);
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
if (request !== requestVersion.current) return;
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
};
const ara = async (sayfa = 0) => {
if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod), sayfa);
const araOkas = (kodlar, sayfa = 0) => {
if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; }
return sorgula({ mod: 'okas', label: kodlar.join(', '), kodlar: [...kodlar], teknik: false }, sayfa);
};
const ara = () => {
if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod));
const terim = q.trim();
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra(terim, { skip: sayfa * SAYFA, take: SAYFA, teknik });
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
return sorgula({ mod: 'metin', label: terim, teknik });
};
const sorguDegisti = submitted && (submitted.mod !== mod ||
(mod === 'metin'
? submitted.label !== q.trim() || submitted.teknik !== teknik
: submitted.label !== okasSecili.map((k) => k.kod).join(', ')));
const okasEkle = (k) => {
if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]);
setOkasQ(''); setOkasAdaylar([]);
@@ -112,7 +130,7 @@ export const UrunAramaModal = () => {
</p>
</div>
</div>
<button onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<button aria-label="Ürün aramasını kapat" onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<X className="w-6 h-6" />
</button>
</div>
@@ -220,13 +238,17 @@ export const UrunAramaModal = () => {
</div>
)}
{sonuc && sorguDegisti && (
<p role="status" className="text-xs text-amber-800">Arama alanları değişti. Aşağıdaki sonuçlar ve sayfalar önceki aramaya aittir. Yeni seçimler için Ara düğmesine basın.</p>
)}
{sonuc && (
<>
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<p className="text-xs font-bold text-slate-900">
{mod === 'okas'
? <>OKAS <span className="text-indigo-700">{okasSecili.map((k) => k.kod).join(', ')}</span> kodlu{' '}</>
: <>Teklif cetvelinde <span className="text-indigo-700">"{q.trim()}"</span> geçen{' '}</>}
{submitted?.mod === 'okas'
? <>OKAS <span className="text-indigo-700">{submitted?.label}</span> kodlu{' '}</>
: <>Teklif cetvelinde <span className="text-indigo-700">"{submitted?.label}"</span> geçen{' '}</>}
<span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu
</p>
{gizlenen > 0 && (
@@ -281,7 +303,7 @@ export const UrunAramaModal = () => {
<div className="flex items-center justify-center gap-3 pt-2">
<button
disabled={durum.sayfa === 0 || durum.yukleniyor}
onClick={() => ara(durum.sayfa - 1)}
onClick={() => sorgula(submitted, durum.sayfa - 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
← Önceki
@@ -291,7 +313,7 @@ export const UrunAramaModal = () => {
</span>
<button
disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor}
onClick={() => ara(durum.sayfa + 1)}
onClick={() => sorgula(submitted, durum.sayfa + 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
Sonraki →
+5 -5
View File
@@ -106,7 +106,7 @@ export const PriceAnalysis = () => {
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Birim Fiyat Dağılımı (₺)</h3>
<h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Kategoriye Dağıtılmış Sözleşme Tutarı (₺)</h3>
<span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">{selected?.count} gerçek kayıt</span>
</div>
<div className="h-64 w-full">
@@ -134,12 +134,12 @@ export const PriceAnalysis = () => {
<div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-2 shadow-md border border-emerald-900/40">
<div className="flex items-center gap-2 font-bold text-sm text-emerald-400">
<Sparkles className="w-5 h-5 text-amber-400" /> Teklif Önerisi (gerçek veriye dayalı)
<Sparkles className="w-5 h-5 text-amber-400" /> Karşılaştırmanın kapsamı
</div>
<p className="text-xs text-slate-300 leading-relaxed">
<strong>{selected?.ad}</strong> için toplanan {selected?.count} gerçek kayda göre rekabetçi birim fiyat aralığınız{' '}
<strong className="text-amber-300">{formatTL(selected?.min)} – {formatTL(selected?.median)}</strong> arasıdır.
Medyanın altında teklif vermek kazanma olasılığını artırır; ortalamanın ({formatTL(selected?.avg)}) üstü riskli olabilir.
<strong>{selected?.ad}</strong> grubunda {selected?.count} kaydın medyan kategori tutarı {formatTL(selected?.median)}.
Bunlar ürün birim fiyatı değildir. Test adedi, ambalaj birimi, cihaz ve hizmet kapsamı doğrulanmadan
bu tutarlardan teklif fiyatı veya kazanma olasılığı çıkarılamaz.
</p>
</div>
</div>
+2 -2
View File
@@ -279,10 +279,10 @@ export const AppProvider = ({ children }) => {
.then(detail => {
if (cancelled || !detail.aciklama) return;
setActiveTenderDetail(prev =>
prev && prev.id === t.id ? { ...prev, aciklama: detail.aciklama } : prev
prev && prev.id === t.id ? { ...prev, ...detail } : prev
);
setTenders(prev =>
prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : x))
prev.map(x => (x.id === t.id ? { ...x, ...detail } : x))
);
})
.catch(() => {});
+7 -7
View File
@@ -1,3 +1,4 @@
import { quantityParts, bidRowTotal, validateBidRows } from './bidValidation.js';
/**
* İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve
* evrak kontrol listesi.
@@ -53,12 +54,7 @@ export const paraTR = (n) =>
(Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
/** "3 adet" → { miktar: 3, birim: "adet" } ; okunamazsa miktar null */
export function miktarAyristir(q) {
const m = /(-?[\d.,]+)\s*(.*)$/.exec(String(q || '').trim());
if (!m) return { miktar: null, birim: String(q || '').trim() };
const sayi = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
return { miktar: isFinite(sayi) ? sayi : null, birim: (m[2] || '').trim() };
}
export const miktarAyristir = quantityParts;
const kacir = (s) =>
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
@@ -73,6 +69,8 @@ const STIL = `
.ust div { margin: .6mm 0; }
table { width: 100%; border-collapse: collapse; margin: 3mm 0; font-size: 9.5pt; }
th, td { border: 1px solid #444; padding: 1.6mm 2mm; text-align: left; vertical-align: top; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
th { background: #eee; font-weight: bold; }
td.s, th.s { text-align: right; white-space: nowrap; }
.toplam td { font-weight: bold; background: #f4f4f4; }
@@ -160,10 +158,12 @@ ihale dokümanındaki standart form metniyle karşılaştırınız.</p>`;
/** Birim fiyat teklif cetveli. */
export function teklifCetveliHtml(f, satirlar, toplam) {
const errors = validateBidRows(satirlar);
if (errors.length) throw new Error(errors.join(" "));
const govde = satirlar.map((r, i) => {
const { miktar, birim } = miktarAyristir(r.quantity);
const bf = Number(r.birimFiyat) || 0;
const tutar = (miktar ?? 0) * bf;
const tutar = bidRowTotal(r);
return `<tr>
<td class="s">${i + 1}</td>
<td>${kacir(r.name)}</td>
+24
View File
@@ -0,0 +1,24 @@
export function quantityParts(value) {
if (typeof value === 'number') return {miktar:Number.isFinite(value) ? value : null,birim:''};
const match = /^\s*([+-]?[\d]+(?:[.,]\d+)*)\s+(.+?)\s*$/.exec(String(value || ''));
if (!match) return {miktar:null,birim:''};
const raw = match[1];
const normalized = raw.includes(',') ? raw.replace(/\./g,'').replace(',','.')
: /^\d{1,3}(?:\.\d{3})+$/.test(raw) ? raw.replace(/\./g,'') : raw;
const number = Number(normalized);
return {miktar:Number.isFinite(number) ? number : null,birim:match[2]};
}
export const roundMoney = n => Math.round((n + Number.EPSILON) * 100) / 100;
export function bidRowTotal(row) {
const {miktar} = quantityParts(row.quantity);
const price = Number(row.birimFiyat);
return miktar > 0 && Number.isFinite(price) && price > 0 ? roundMoney(miktar * price) : null;
}
export function validateBidRows(rows) {
if (!rows.length) return ['Resmi teklif cetvelindeki kalemler eksik.'];
return rows.flatMap((row,i) => {
const {miktar,birim} = quantityParts(row.quantity);
return (!row.name?.trim() || !(miktar > 0) || !birim || bidRowTotal(row) === null)
? [`${i+1}. kalemin adı, miktarı, birimi veya fiyatı eksik/geçersiz.`] : [];
});
}