Add saved bid preparation overview with actionable missing checks

This commit is contained in:
2026-09-06 13:26:29 +03:00
parent 2e98e5ad4f
commit 30ce6e3a6c
9 changed files with 168 additions and 13 deletions
+8 -12
View File
@@ -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);
+2
View File
@@ -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 => {
+46
View File
@@ -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>;
}
+2 -1
View File
@@ -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} />
)}
+11
View File
@@ -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 },
];
+41
View File
@@ -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.'}};
}