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
+18
View File
@@ -0,0 +1,18 @@
{
"date": "2026-09-06",
"environment": "localhost:5182 five synthetic tenders, real UI/API save",
"checks": {
"fiveDossiers": true,
"allUnassignedVisible": true,
"directFieldFocus": true,
"savedFieldCleared": true,
"ownerUpdated": true,
"reloadPreserved": true
},
"limits": [
"Synthetic incomplete dossiers, not five official bid approvals",
"One direct field focus, saved edit and one owner assignment tested",
"Distinct owners and document revision errors covered by unit tests",
"Real PDF/official documents remain open"
]
}
+8
View File
@@ -874,3 +874,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Kayıt sırasında durum/atama/sürükleme kilitlenir; mevcut sunucu kayıt hatası panoda gösterilir. Durum iyimser olarak değiştirilmez, mevcut mutateTender kalıcılık akışı korunur.
- Yerel 5182 sentetik Giresun görevinde 5 kontrol geçti: sahte düğmenin kalkması, desteklenmeme açıklaması, durum seçimi, yenileme sonrası durumun korunması ve erişilebilir kontrol adları. Kanıt qa-kanban-controls.json. Derleme geçti. Önceki 197 test tekrar çalıştırılmadı; sunucu değişmedi.
- Bu tur atama mutasyonu, hata enjeksiyonu, yalnız klavyeyle tam gezinme veya beş dosyalı eksik/sorumlu/sonraki işlem özetinin kabulünü kanıtlamaz. D07'nin ortak hazırlık özeti açık.
### D07 — Beş dosyalı hazırlık özeti — 6 Eylül 2026
- Kanban'a Hazırlık dosyaları özeti eklendi. Aynı takip/sorumlu filtresindeki tüm ihaleler için sorumlu, kayıtlı taslaktaki kontrol sayısı, belge eksikleri ve sıradaki kontrol bir tabloda gösterilir. Kaydedilmemiş form değişiklikleri özetin dışında olduğu açıklanır; temiz özet başvuru onayı olarak sunulmaz.
- preparationSummary mevcut kaynak/kalem/kısım, firma alanları, teklif türü/geçerliliği, fiyat, sağlık koşulları ve belge bağlantı/sürüm/süre kontrollerini kullanır. Zorunlu firma alanları ortak modüle taşındı. Belge deposu hata/yükleme durumları gösterilir; yenileme düğmesi vardır.
- Özet bağlantıları detayın doğrudan hazırlık sekmesini açar ve ilgili firma/fiyat/kontrol alanına odak taşır. Belge ve ürün eksikleri ilgili kontrol bölümüne gider. Çıktı öncesi canlı belge doğrulamaları korunur.
- 200 otomatik test ve derleme geçti. Beş ayrı sorumlu/belge içeren taslak özeti, eski belge sürümü/depo hatası ve geçmiş deadline önceliği test edildi.
- 5182 yerel tarayıcıda beş sentetik görev tek tabloda görüldü; vergi alanına doğrudan odak, kaydedilen düzeltmenin özetten kalkması, sorumlu atamasının yansıması ve yenileme sonrası kalıcılık dahil 6 kontrol geçti (qa-preparation-overview.json).
- Bu sentetik kabul gerçek beş resmi dosyanın uygunluğunu kanıtlamaz. Firma portföyü/ürün kapasitesi, tam mobil erişilebilirlik, her eksik belgeye ayrı alt-alan odağı ve resmi çıktılarla ilgili açık maddeler devam eder. Özet kontrolleri kayıtlı taslağa dayanır; yeni AI analizi veya kaydedilmemiş değişiklikler dosya ekranında ayrıca değerlendirilir.
+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.'}};
}
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {preparationSummary} from '../src/services/preparationSummary.js';
import {documentKey,documentRevision} from '../src/services/preparationChecks.js';
const now=new Date('2026-09-06T10:00:00Z');
const tender={id:'test',ikn:'2026/1',tarih:'10.09.2026',saat:'10:00',baslik:'Lab',items:[{ad:'Kit',miktar:2,birim:'adet'}]};
const vault={loading:false,error:null,documents:[]};
test('five saved dossiers retain distinct owners, missing documents and actionable targets',()=>{
const rows=Array.from({length:5},(_,i)=>({...tender,id:`t-${i}`,ikn:`2026/${i}`,atananPersonel:`Sorumlu ${i}`,bidDraft:{manualDocuments:[{document:`Belge ${i}`}],teklifTuru:'birim'}}));
rows.forEach((row,i)=>{
const result=preparationSummary(row,vault,now);
assert.equal(result.owner,`Sorumlu ${i}`);
assert.ok(result.documentIssues.some(text=>text.includes(`Belge ${i}`)));
assert.equal(result.next.target,'source');
assert.ok(result.issues.some(issue=>issue.target.startsWith('price:')));
assert.ok(result.issues.some(issue=>issue.target==='firm:vergiNo'));
});
});
test('document revision and vault failures invalidate a previously verified saved document',()=>{
const doc={document:'Ürün belgesi'};
const file={id:'f',tur:'firma',olusturma:'2026-09-01',boyut:20,son_kullanma:'2026-12-31'};
const draft={manualDocuments:[doc],checks:{documents:{[documentKey(doc)]:{status:'verified',evidence:'Madde 2',documentId:'f',documentRevision:documentRevision(file)}}}};
assert.deepEqual(preparationSummary({...tender,bidDraft:draft},{...vault,documents:[file]},now).documentIssues,[]);
assert.ok(preparationSummary({...tender,bidDraft:draft},{...vault,documents:[{...file,boyut:21}]},now).documentIssues.some(text=>text.includes('değişti')));
assert.ok(preparationSummary({...tender,bidDraft:draft},{...vault,error:'offline'},now).documentIssues.some(text=>text.includes('doğrulanamadı')));
});
test('expired source takes priority over completion or manually moved Kanban status',()=>{
const summary=preparationSummary({...tender,tarih:'01.09.2026',kanbanStatus:'teklif_verildi'},vault,now);
assert.match(summary.next.text,/zamanı geçti/);
assert.equal(summary.next.target,'source');
assert.equal(summary.owner,'Atanmadı');
});