Add saved bid preparation overview with actionable missing checks
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { bidFirmFields as ALANLAR } from '../services/bidFirmFields';
|
||||
import { preparationAnalysisSource, preparationConditionSections, conditionText } from '../services/preparationAnalysis';
|
||||
import { preparationDeadlineProblem } from '../services/preparationDeadline';
|
||||
import { bidValidityForForm, bidTypeForForm } from '../services/bidTerms';
|
||||
@@ -29,23 +30,13 @@ import {
|
||||
* 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 }) => {
|
||||
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus }) => {
|
||||
const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
|
||||
const tender = {...selectedTender, ...tenders.find(t => t.id === selectedTender?.id)};
|
||||
const storedDraft = tender?.bidDraft || {};
|
||||
@@ -67,6 +58,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PreparationOverview } from './PreparationOverview';
|
||||
import { useDeadlineClock } from '../hooks/useDeadlineClock';
|
||||
import { deadlineInfo } from '../services/tenderCalendar';
|
||||
import React, { useState } from 'react';
|
||||
@@ -91,6 +92,7 @@ export const KanbanBoard = () => {
|
||||
|
||||
{savingCount > 0 && <p role="status" className="text-sm text-blue-800">Değişiklik kaydediliyor…</p>}
|
||||
{saveError && <p role="alert" className="text-sm text-red-700">{saveError}</p>}
|
||||
<PreparationOverview tenders={filteredTenders} now={now}/>
|
||||
{/* Kanban Columns Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3.5 overflow-x-auto pb-4">
|
||||
{columns.map(col => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {listDocuments} from '../services/account';
|
||||
import {preparationSummary} from '../services/preparationSummary';
|
||||
import {useApp} from '../context/AppContext';
|
||||
import {getToken} from '../services/auth';
|
||||
|
||||
export function PreparationOverview({tenders,now}) {
|
||||
const {authUser}=useApp();
|
||||
return <Overview key={`${authUser?.email}:${getToken()}`} tenders={tenders} now={now}/>;
|
||||
}
|
||||
function Overview({tenders,now}) {
|
||||
const {setActiveTenderDetail}=useApp();
|
||||
const [vault,setVault]=useState({loading:true,error:null,documents:[]}),[refresh,setRefresh]=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;};
|
||||
},[refresh]);
|
||||
const open=(tender,target)=>setActiveTenderDetail({...tender,preparationTarget:target});
|
||||
return <section aria-label="Hazırlık dosyaları özeti" className="bg-white border rounded-xl p-4 space-y-3">
|
||||
<h2 className="font-bold text-lg">Hazırlık dosyaları · {tenders.length}</h2>
|
||||
<p className="text-xs text-slate-600">Panodaki ve sorumlu filtresindeki kayıtlı taslaklar gösterilir. Kaydetmediğiniz değişiklikler bu özete girmez. Eksik sayısı başvuru onayı değildir; çıktı sırasında belgeler yeniden kontrol edilir.</p>
|
||||
<button className="border rounded px-3 py-2 text-xs" disabled={vault.loading} onClick={()=>setRefresh(n=>n+1)}>Belge durumunu yenile</button>
|
||||
{vault.loading&&<p role="status" className="text-xs">Belge durumu kontrol ediliyor…</p>}
|
||||
{vault.error&&<p role="alert" className="text-xs text-red-700">Belge durumu doğrulanamadı: {vault.error}</p>}
|
||||
{!tenders.length ? <p className="text-sm">İhaleyi takibe alın veya hazırlık görevi açın; dosyası burada görünsün.</p> :
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm min-w-[700px]">
|
||||
<thead><tr className="text-left border-b"><th className="p-2">İhale</th><th className="p-2">Sorumlu</th><th className="p-2">Belge eksikleri</th><th className="p-2">Sıradaki kontrol</th></tr></thead>
|
||||
<tbody>{tenders.map(tender=>{
|
||||
const summary=preparationSummary(tender,vault,now);
|
||||
return <tr key={tender.id} className="border-b align-top">
|
||||
<td className="p-2"><button onClick={()=>open(tender,'source')} className="text-blue-800 underline text-left">{tender.ikn} · {tender.baslik}</button><p className="text-xs mt-1">{summary.issues.length ? `${summary.issues.length} kontrol eksik` : 'Kayıtlı kontroller tamam; son kontrol gerekli'}</p></td>
|
||||
<td className="p-2">{summary.owner}</td>
|
||||
<td className="p-2"><details><summary className="cursor-pointer">{summary.documentIssues.length ? `${summary.documentIssues.length} belge kontrolü` : 'Kayıtlı belgelerde eksik bulunmadı'}</summary>
|
||||
<ul className="space-y-2 mt-2">{summary.documentIssues.map((text,i)=><li key={i}><button onClick={()=>open(tender,'checks')} className="text-blue-800 underline text-left">{text}</button></li>)}</ul>
|
||||
</details></td>
|
||||
<td className="p-2"><button onClick={()=>open(tender,summary.next.target)} className="text-blue-800 underline text-left">{summary.next.text}</button>
|
||||
<details className="mt-2"><summary className="cursor-pointer text-xs">Tüm eksikleri göster</summary><ul className="space-y-2 mt-2">{summary.issues.map((item,i)=><li key={i}><button onClick={()=>open(tender,item.target)} className="text-blue-800 underline text-left">{item.text}</button></li>)}</ul></details>
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export const TenderDetailModal = () => {
|
||||
}
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
|
||||
useEffect(()=>{setActiveTab(activeTenderDetail?.preparationTarget ? 'pdf_stamp' : 'details');},[activeTenderDetail?.id,activeTenderDetail?.preparationTarget]);
|
||||
const noteOwner = authUser?.email || '';
|
||||
const [noteDrafts, setNoteDrafts] = useState(() => ({ owner: noteOwner, drafts: {} }));
|
||||
// Discard private drafts on account changes, before rendering another account.
|
||||
@@ -434,7 +435,7 @@ export const TenderDetailModal = () => {
|
||||
|
||||
{/* Tab 2: Teklif dosyası hazırlığı */}
|
||||
{activeTab === 'pdf_stamp' && (
|
||||
<BidPackageBuilder key={tender.ikn || tender.id} tender={tender}
|
||||
<BidPackageBuilder key={tender.ikn || tender.id} tender={tender} initialFocus={activeTenderDetail?.preparationTarget}
|
||||
results={aiAnalysisResults?.tenderKey === (tender.ikn || tender.id) ? aiAnalysisResults : null} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const bidFirmFields = [
|
||||
{ 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 },
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
import {bidFirmFields} from './bidFirmFields.js';
|
||||
import {sourceRows,bidSourceOptions,bidSourceSnapshot,restoreBidPrices} from './bidSource.js';
|
||||
import {selectBidLots} from './bidLots.js';
|
||||
import {bidTotal,bidAmount,validateBidRows} from './bidValidation.js';
|
||||
import {bidTypeForForm,bidValidityForForm} from './bidTerms.js';
|
||||
import {documentKey,preparationBlockers} from './preparationChecks.js';
|
||||
import {preparationDeadlineProblem} from './preparationDeadline.js';
|
||||
import {tenderDateKey} from './tenderCalendar.js';
|
||||
|
||||
// Saved draft diagnostics only. A clean summary still requires live output checks.
|
||||
export function preparationSummary(tender,vault,now=new Date()) {
|
||||
const draft=tender.bidDraft || {},analysis=draft.analysisSource || {},issues=[];
|
||||
const add=(target,text)=>issues.push({target,text});
|
||||
const deadline=preparationDeadlineProblem(tender,now);
|
||||
if(deadline)add('source',deadline);
|
||||
const documents=[...new Map([...(analysis.documentChecklist || []),...(draft.manualDocuments || [])].map(d=>[documentKey(d),d])).values()];
|
||||
const itemSource=draft.itemSource || {},partial=draft.partialBid || {enabled:false,source:'',selected:[],assignments:{}};
|
||||
const options=bidSourceOptions(tender,analysis,itemSource),rows=sourceRows(tender,analysis,options.selected || 'tender');
|
||||
const selection=selectBidLots(rows,partial);
|
||||
const snapshot=JSON.stringify([bidSourceSnapshot(tender,{...analysis,documentChecklist:documents},rows),partial,itemSource,options.signature]);
|
||||
if(draft.reviewedSource!==snapshot)add('source','Resmi kaynak karşılaştırması gerekli.');
|
||||
if(analysis.sourceReview?.status==='partial')add('analysis','Analiz kısmi; kaynak kontrolünü tamamlayın.');
|
||||
if(options.needsSelection)add('itemSource','Resmi cetvele uygun kalem listesini seçin.');
|
||||
for(const field of bidFirmFields)if(field.zorunlu&&!String(draft.firma?.[field.k] || '').trim())add(`firm:${field.k}`,`${field.l} kayıtlı taslakta eksik.`);
|
||||
const type=bidTypeForForm(tender.aciklama,analysis.procurementTerms?.bidType),bidType=draft.teklifTuru || type.type || '';
|
||||
if(!['birim','goturu'].includes(bidType)||type.status==='conflict'||(type.type&&type.type!==bidType))add('bidType','Teklif türünü doğrulayın.');
|
||||
const validity=bidValidityForForm(tender.aciklama,analysis.procurementTerms?.bidValidity),days=Number(draft.gecerlilikGun || validity.days || '');
|
||||
if(!Number.isInteger(days)||days<=0||validity.status==='conflict'||(validity.days&&days<validity.days))add('validity','Teklif geçerlilik süresini doğrulayın.');
|
||||
selection.errors.forEach(text=>add('lots',text));
|
||||
const prices=restoreBidPrices(draft),priced=selection.rows.map(row=>({...row,birimFiyat:prices[row.priceKey]}));
|
||||
if(bidType==='birim') {
|
||||
if(!priced.length)add('pricing','Resmi teklif kalemleri eksik.');
|
||||
priced.forEach((row,i)=>{if(validateBidRows([row]).length)add(`price:${row.priceKey}`,`${i+1}. kalemin miktar, birim veya fiyatını tamamlayın.`);});
|
||||
if(priced.length&&!validateBidRows(priced).length&&bidTotal(priced)===null)add('pricing','Genel toplam geçersiz veya hesaplama sınırını aşıyor.');
|
||||
}
|
||||
if(bidType==='goturu'&&bidAmount(draft.goturuBedel)===null)add('lumpSum','Toplam teklif bedelini kontrol edin.');
|
||||
const checkedVault={...vault,deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn};
|
||||
const documentIssues=preparationBlockers([],documents,draft.checks,checkedVault);
|
||||
preparationBlockers(selection.rows,documents,draft.checks,checkedVault).forEach(text=>add('checks',text));
|
||||
return {issues,documentIssues,owner:tender.atananPersonel || 'Atanmadı',next:issues[0] || {target:'source',text:'Dosyayı açıp son kontrolleri yapın.'}};
|
||||
}
|
||||
Reference in New Issue
Block a user