Add health product portfolio and explicit tender item selection

This commit is contained in:
2026-09-06 13:34:06 +03:00
parent f7a04dadf5
commit 8e37b200e9
7 changed files with 77 additions and 2 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"date": "2026-09-06",
"environment": "localhost:5182 synthetic profile and tender",
"checks": {
"saved": true,
"reloadPreserved": true,
"selectedDeclaration": true,
"suitabilityReset": true,
"outputStillBlocked": true,
"draftSelectionPreserved": true
},
"limits": [
"Synthetic declarations only, no official UTS check",
"One device portfolio record and one tender row tested",
"Profile later changes do not automatically overwrite the saved tender snapshot"
]
}
+7
View File
@@ -897,3 +897,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Sahte ilerleme yüzdeleri, konfeti, doğrulanmış sektör/ÜTS iddiaları kaldırıldı. Modal başlığı/alanlar/kapama etiketlendi; odak geri dönüşü, Escape ve Tab sınırı eklendi.
- 202 otomatik test ve derleme geçti. Yerel normal hesapta taramasız ekleme, kaydetmeden kapatma, kayıt onayı, yenileme sonrası kalıcılık, yapılandırılmamış tarama hatası ve mevcut ürünü koruma dahil 6 tarayıcı kontrolü geçti (qa-company-profile.json). Başarılı tarama öneri dönüşümü stub testte doğrulandı; gerçek AI/site taraması yapılmadı.
- Marka/model, yapılandırılmış ÜTS durumu ve servis kapasitesi portföyü henüz eklenmedi. Çok cihazlı eşzamanlı profil düzenleme ve başarılı gerçek tarama kabulü açık. Profil kaydı için mevcut hesap veri deposu kullanılır; bu tur yeni sunucu şeması yoktur.
### D07 — Yapılandırılmış sağlık portföyü ve kalem seçimi — 6 Eylül 2026
- Firma profilinde kalıcı healthPortfolio kayıtları eklendi: ürün grubu, marka, model/hizmet adı, ÜTS beyanı ve açıklaması, kurulum/bakım/servis kapasitesi. Sarf/test, cihaz/servis ve personelli hizmet kapsamları ayrı seçilir. Tüm alanlar firma beyanı olarak sunulur.
- Web taraması önerileri alınırken elle oluşturulmuş sağlık portföyü korunur. Portföy mevcut açık profil kaydetme akışıyla hesap deposuna yazılır.
- Hazırlık kaleminde portföyden ürün seçilebilir; seçimin kimliği ve o andaki beyan kopyası bidDraft içinde saklanır. Ürün seçimi veya marka/model değişimi önceki teknik kaynak/uygunluk/ÜTS/numune/servis/teslim kontrollerini sıfırlar; uygunluk otomatik verilmez. Yeniden kontrol gerekliliği alanda açıklanır.
- 203 otomatik test ve derleme geçti. Seçilen beyan kopyasının kaynak nesne değişince korunması ve bütün teknik onayların pending kalması test edildi. Yerel cihaz portföyü kaydı/yenileme, kaleme seçim, eski uygunluğu sıfırlama, çıktı engeli ve taslakta seçimin korunması dahil 6 tarayıcı kontrolü geçti (qa-health-portfolio.json).
- Sentetik ÜTS beyanı resmi kayıt doğrulaması değildir. Portföy sonradan değişince mevcut teklifin seçilmiş kopyası otomatik güncellenmez; kullanıcı yeniden seçebilir. Bu tur tek cihaz/tek kalem sınandı; gerçek firma portföyü, beş resmi dosya ve dış çıktı kabulleri açık.
+3 -1
View File
@@ -1,3 +1,4 @@
import {HealthPortfolioEditor} from './HealthPortfolioEditor';
import React,{useState,useEffect,useRef,useId} from 'react';
import {useApp} from '../context/AppContext';
import {getToken} from '../services/auth';
@@ -24,7 +25,7 @@ function ProfileEditor(){
const scan=e=>{e.preventDefault();void run('scan',async()=>{
controller.current=new AbortController();
const proposal=await scanCompanyWebsite(url,{signal:controller.current.signal});
if(alive.current){setCaps(proposal);setMessage('Tarama önerileri alındı. Alanları kontrol edip düzenleyin; henüz profilinize kaydedilmedi.');}
if(alive.current){setCaps(previous=>({...proposal,healthPortfolio:previous.healthPortfolio || []}));setMessage('Tarama önerileri alındı. Alanları kontrol edip düzenleyin; henüz profilinize kaydedilmedi.');}
});};
const save=()=>run('save',async()=>{
await updateCompanyCapabilities(caps,{website:url,threshold});
@@ -61,6 +62,7 @@ function ProfileEditor(){
<ul className="space-y-2">{(caps[key] || []).map((value,index)=><li key={`${value}:${index}`} className="flex justify-between gap-2 text-sm break-words"><span>{value}</span><button type="button" aria-label={`${label}: ${value} kaldır`} onClick={()=>edit(key,caps[key].filter((_,i)=>i!==index))} className="text-red-700 shrink-0">Kaldır</button></li>)}</ul>
</section>)}
</fieldset>
<HealthPortfolioEditor items={caps.healthPortfolio || []} disabled={!!busy} onChange={items=>edit('healthPortfolio',items)}/>
<label className="block text-sm">Uyum eşiği: %{threshold}<input aria-label="Uyum eşiği" type="range" min={30} max={95} step={5} disabled={!!busy} value={threshold} onChange={e=>{setThreshold(Number(e.target.value));setMessage('Kaydedilmemiş değişiklikler var.');}} className="block w-full"/></label>
<p className="text-xs text-slate-500">Uyum skoru terim eşleşmesi ve varsa anlamsal yakınlık girdisidir; başvuru yeterliliği değildir.</p>
<button type="button" onClick={save} disabled={!!busy} className="bg-blue-700 text-white px-4 py-2 rounded disabled:opacity-50">Profili kaydet</button>
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import {portfolioFields} from '../services/healthPortfolio';
export function HealthPortfolioEditor({items,onChange,disabled}){
const patch=(id,change)=>onChange(items.map(item=>item.id===id?{...item,...change}:item));
return <fieldset disabled={disabled} className="border rounded-xl p-3 space-y-3">
<legend className="font-bold">Sağlık ürün ve hizmet portföyü</legend>
<p className="text-xs text-slate-600">Firma beyanıdır; resmi kayıt veya teknik uygunluk doğrulaması değildir. İhale hazırlığında seçilen ürünün bu sürümü taslakta tutulur.</p>
{items.map((item,index)=><section key={item.id} className="border rounded p-3 grid sm:grid-cols-2 gap-2">
<h3 className="font-bold text-sm sm:col-span-2">Portföy kaydı {index+1}</h3>
{portfolioFields.map(([key,label])=><label key={key} className="text-xs">{label}<input aria-label={`Portföy ${index+1} ${label}`} className="block w-full border rounded p-2" maxLength={1000} value={item[key] || ''} onChange={e=>patch(item.id,{[key]:e.target.value})}/></label>)}
<label className="text-xs">Kapsam<select aria-label={`Portföy ${index+1} Kapsam`} className="block w-full border rounded p-2" value={item.kind || 'consumable'} onChange={e=>patch(item.id,{kind:e.target.value})}><option value="consumable">Sarf / test ürünü</option><option value="device">Cihaz ve servis</option><option value="staffedService">Personelli hizmet</option></select></label>
<label className="text-xs">ÜTS beyanı<select aria-label={`Portföy ${index+1} ÜTS beyanı`} className="block w-full border rounded p-2" value={item.utsStatus || 'unknown'} onChange={e=>patch(item.id,{utsStatus:e.target.value})}><option value="unknown">Kontrol gerekli</option><option value="declared">Kayıt mevcut — firma beyanı</option><option value="notApplicable">Uygulanmıyor — açıklama gerekli</option></select></label>
<button type="button" className="text-red-700 text-xs" onClick={()=>onChange(items.filter(row=>row.id!==item.id))}>Portföy kaydı {index+1} kaldır</button>
</section>)}
<button type="button" className="border rounded p-2 text-sm" onClick={()=>onChange([...items,{id:crypto.randomUUID(),kind:'consumable',utsStatus:'unknown'}])}>Portföy kaydı ekle</button>
</fieldset>;
}
+14 -1
View File
@@ -1,3 +1,5 @@
import {useApp} from '../context/AppContext';
import {portfolioProductText,selectPortfolioProduct} from '../services/healthPortfolio';
import React, {useState} from 'react';
import {HEALTH_CHECKS, documentKey, documentRevision, documentScopeProblem} from '../services/preparationChecks';
const fieldClass = 'w-full border border-slate-300 rounded-lg p-2 text-xs bg-white';
@@ -6,6 +8,8 @@ const Status = ({value, onChange, optional = true}) => <select aria-label="Kontr
{optional && <option value="notApplicable">Uygulanmıyor — gerekçe gerekli</option>}
</select>;
export function PreparationChecks({rows, documents, manualDocuments, setManualDocuments, checks, setChecks, vault, refreshVault, downloadFile}) {
const {userProfile}=useApp();
const portfolio=userProfile.scrapedCapabilities?.healthPortfolio || [];
const eligibleFiles = vault.documents.filter(file => !documentScopeProblem(file,vault.tenderIkn));
const [name,setName] = useState('');
const [clause,setClause] = useState('');
@@ -56,7 +60,16 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo
return <details key={row.priceKey} className="border rounded-xl p-3">
<summary className="cursor-pointer text-xs font-bold">{row.name} — ürün/hizmet ve yükümlülük kontrolü</summary>
<div className="space-y-2 mt-3">
<label className="block text-xs">Teklif edilen marka/model veya hizmet kapsamı<input className={fieldClass} value={c.product || ''} onChange={e=>updateProduct(row.priceKey,{product:e.target.value})}/></label>
<p className="text-xs text-slate-600">Ürün seçimini veya marka/model metnini değiştirirseniz teknik kontroller ve kaynak açıklamaları yeniden doldurulmalıdır.</p>
<label className="block text-xs">Firma portföyünden seç
<select className={fieldClass} value={c.portfolioId || ''} onChange={e=>updateProduct(row.priceKey,selectPortfolioProduct(portfolio.find(item=>item.id===e.target.value)))}>
<option value="">Elle kapsam girin</option>
{c.portfolioId&&!portfolio.some(item=>item.id===c.portfolioId)&&<option value={c.portfolioId}>Önceki portföy kaydı kaldırılmış</option>}
{portfolio.filter(item=>portfolioProductText(item)).map(item=><option key={item.id} value={item.id}>{portfolioProductText(item)}</option>)}
</select>
</label>
{c.portfolioSnapshot&&<p className="text-xs text-slate-600">Seçim anındaki firma beyanı: {c.portfolioSnapshot.kind==='device'?'Cihaz ve servis':c.portfolioSnapshot.kind==='staffedService'?'Personelli hizmet':'Sarf / test ürünü'} · ÜTS: {{declared:'Kayıt mevcut — firma beyanı',notApplicable:'Uygulanmıyor — firma beyanı',unknown:'Kontrol gerekli'}[c.portfolioSnapshot.utsStatus] || 'Kontrol gerekli'} · {c.portfolioSnapshot.utsReference || 'Kayıt açıklaması yok'} · Servis: {c.portfolioSnapshot.serviceCapacity || 'Belirtilmedi'}. İhale koşullarını aşağıda ayrıca doğrulayın.</p>}
<label className="block text-xs">Teklif edilen marka/model veya hizmet kapsamı<input className={fieldClass} value={c.product || ''} onChange={e=>updateProduct(row.priceKey,{...selectPortfolioProduct(null),product:e.target.value})}/></label>
<label className="block text-xs">Teknik doküman/madde ve uygunluk dayanağı<input className={fieldClass} value={c.source || ''} onChange={e=>updateProduct(row.priceKey,{source:e.target.value})}/></label>
<label className="block text-xs">Ürün/hizmet uygunluğu<select className={fieldClass} value={c.status || 'pending'} onChange={e=>updateProduct(row.priceKey,{status:e.target.value})}>
<option value="pending">Kontrol gerekli</option><option value="suitable">Uygun</option><option value="unsuitable">Uygun değil</option>
+7
View File
@@ -0,0 +1,7 @@
import {HEALTH_CHECKS} from './preparationChecks.js';
export const portfolioFields=[['group','Ürün grubu'],['brand','Marka'],['model','Model / hizmet adı'],['utsReference','ÜTS kayıt numarası veya açıklaması'],['serviceCapacity','Kurulum / bakım / servis kapasitesi']];
export function portfolioProductText(item){return [item.group,item.brand,item.model].filter(Boolean).join(' · ');}
export function selectPortfolioProduct(item){
return {portfolioId:item?.id || null,portfolioSnapshot:item ? {...item} : null,product:item ? portfolioProductText(item) : '',source:'',status:'pending',
...Object.fromEntries(HEALTH_CHECKS.map(([key])=>[key,{status:'pending',evidence:''}]))};
}
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {selectPortfolioProduct} from '../src/services/healthPortfolio.js';
import {HEALTH_CHECKS} from '../src/services/preparationChecks.js';
test('portfolio selection preserves declaration snapshot without approving tender requirements',()=>{
const item={id:'device',group:'Analiz',brand:'QA',model:'M1',kind:'device',utsStatus:'declared',utsReference:'TEST',serviceCapacity:'2 uzman'};
const check=selectPortfolioProduct(item);item.model='Changed';
assert.equal(check.portfolioSnapshot.model,'M1');assert.equal(check.product,'Analiz · QA · M1');
assert.equal(check.status,'pending');assert.equal(check.source,'');
for(const [key] of HEALTH_CHECKS)assert.deepEqual(check[key],{status:'pending',evidence:''});
const manual=selectPortfolioProduct(null);assert.equal(manual.portfolioId,null);assert.equal(manual.portfolioSnapshot,null);
});