fix: preserve bid drafts and align tender source data
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
|
||||
@@ -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.`] : [];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user