Open and retain tender details discovered outside the main pool
This commit is contained in:
@@ -407,3 +407,9 @@ Gerçek 390 px tarayıcı testinde dialog 337/337 px genişlikle taşmadı, ilk
|
|||||||
Ürün arama native dialog, başlık bağlantısı, açılış odağı, açık Tab sınırları ve Escape/kapat sonrası odak dönüşü kullanıyor. Mobil iç boşluklar azaltıldı; 390 px dialog içerik genişliği taşmadı. Metin/OKAS seçimleri aria-pressed taşıyor. Tarayıcı testinde ilk uygulamada Shift+Tab BODY'ye çıktı; açık odak döngüsü eklendi. AutoFocus nedeniyle kapanış kontrolünün yanlışlıkla opener kaydedilmesi de düzeltildi; gerekli durumda header ürün arama düğmesi hedefleniyor.
|
Ürün arama native dialog, başlık bağlantısı, açılış odağı, açık Tab sınırları ve Escape/kapat sonrası odak dönüşü kullanıyor. Mobil iç boşluklar azaltıldı; 390 px dialog içerik genişliği taşmadı. Metin/OKAS seçimleri aria-pressed taşıyor. Tarayıcı testinde ilk uygulamada Shift+Tab BODY'ye çıktı; açık odak döngüsü eklendi. AutoFocus nedeniyle kapanış kontrolünün yanlışlıkla opener kaydedilmesi de düzeltildi; gerekli durumda header ürün arama düğmesi hedefleniyor.
|
||||||
|
|
||||||
Son gerçek tarayıcı sonucu: ters Tab aktif INPUT ile dialog içinde; Escape sonrası 0 açık dialog ve data-product-search-trigger=true olan BUTTON odakta. Kanıt evidence/qa-product-dialog-keyboard.json. 80 test ve son üretim derlemesi geçti. Gerçek arama sonucu/ihale detay geçişi, OKAS çekmecesinden açılış ve diğer modallerin erişilebilirliği halen açık.
|
Son gerçek tarayıcı sonucu: ters Tab aktif INPUT ile dialog içinde; Escape sonrası 0 açık dialog ve data-product-search-trigger=true olan BUTTON odakta. Kanıt evidence/qa-product-dialog-keyboard.json. 80 test ve son üretim derlemesi geçti. Gerçek arama sonucu/ihale detay geçişi, OKAS çekmecesinden açılış ve diğer modallerin erişilebilirliği halen açık.
|
||||||
|
|
||||||
|
### Ana havuz dışı ürün arama sonucunun detayı
|
||||||
|
|
||||||
|
Ürün arama sonuçlarında geçerli EKAP İKN varsa artık ana havuzda bulunmasa da detay açılır. Sonuç kararlı ekap-İKN kimliğiyle mevcut listeye eklenir; varsa sunucudan yüklenmiş kullanıcı durumu uygulanır. Böylece mevcut takip/hazırlık işlemleri aynı kayıt modeliyle çalışabilir. Detay metni İKN üzerinden ilan API'sinden alınır; yanıt başka İKN taşıyorsa reddedilir. İlan metni bulunmayıp yalnız temel bilgi dönmüşse belirgin kaynak uyarısı gösterilir. Hizmet/mal türü kaynaktan korunur; kalemler ve bilinmeyen tarih/saat uydurulmaz.
|
||||||
|
|
||||||
|
82 test ve üretim derlemesi geçti. Ek testler arama kaydının kararlı kimlik ve kaynak türüyle dönüşmesini, bilinmeyen alanları boş tutmasını, İKN endpoint seçimini, eksik resmi ilan uyarısını ve farklı İKN yanıtının reddini doğruluyor. Gerçek ürün arama yanıtından detay açma/takip kaydetme tarayıcı kabulü hâlâ açık. Ürün aramasının döndürdüğü her alanın resmi dokümanla teyidi ayrıca gereklidir.
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const TenderDetailModal = () => {
|
|||||||
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} onKeyDown={handleDialogKey} className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
|
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} onKeyDown={handleDialogKey} className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
|
||||||
|
|
||||||
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
|
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
|
||||||
|
{tender.sourceWarning && <p role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">{tender.sourceWarning}</p>}
|
||||||
{detailLoad?.id === tender.id && detailLoad.status === 'loading' && <p role="status" className="p-3 bg-blue-50 text-blue-900">Kaynak ilan metni yükleniyor…</p>}
|
{detailLoad?.id === tender.id && detailLoad.status === 'loading' && <p role="status" className="p-3 bg-blue-50 text-blue-900">Kaynak ilan metni yükleniyor…</p>}
|
||||||
{detailLoad?.id === tender.id && detailLoad.status === 'error' && <div role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">
|
{detailLoad?.id === tender.id && detailLoad.status === 'error' && <div role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">
|
||||||
<p>{detailLoad.error}</p>
|
<p>{detailLoad.error}</p>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const gecmisMi = tarihSaat => {
|
|||||||
|
|
||||||
export const UrunAramaModal = () => {
|
export const UrunAramaModal = () => {
|
||||||
const {
|
const {
|
||||||
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
|
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, openSearchTender, tenders,
|
||||||
urunAramaBaslangic, setUrunAramaBaslangic,
|
urunAramaBaslangic, setUrunAramaBaslangic,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const modal = useRef(null);
|
const modal = useRef(null);
|
||||||
@@ -300,12 +300,12 @@ export const UrunAramaModal = () => {
|
|||||||
<span className="flex items-center gap-1"><CalendarDays className="w-3 h-3 text-blue-600" />{t.ihaleTarihSaat}</span>
|
<span className="flex items-center gap-1"><CalendarDays className="w-3 h-3 text-blue-600" />{t.ihaleTarihSaat}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-slate-400">{t.durum}</span>
|
<span className="text-slate-400">{t.durum}</span>
|
||||||
{acik && (
|
{/^\d{4}\/\d+$/.test(String(t.ikn || '')) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
|
onClick={() => { openSearchTender(t); setIsUrunAramaOpen(false); }}
|
||||||
className="font-bold text-indigo-700 hover:underline"
|
className="font-bold text-indigo-700 hover:underline"
|
||||||
>
|
>
|
||||||
Havuzda açık — detayı aç
|
{acik ? 'Listedeki detayı aç' : 'İhale detayını aç'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { searchTender } from '../services/searchTender';
|
||||||
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
|
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
|
||||||
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
|
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||||
@@ -281,6 +282,14 @@ export const AppProvider = ({ children }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
||||||
|
const openSearchTender = (result) => {
|
||||||
|
const existing=tenders.find(t=>t.ikn===result.ikn);
|
||||||
|
const candidate=existing || searchTender(result);
|
||||||
|
const merged=applyTenderStates([candidate],savedTenderStates.current).find(t=>t.ikn===candidate.ikn) || candidate;
|
||||||
|
if(!existing) setTenders(previous=>previous.some(t=>t.ikn===merged.ikn) ? previous : [...previous,merged]);
|
||||||
|
setActiveTenderDetail(merged);
|
||||||
|
};
|
||||||
|
|
||||||
const [detailLoad, setDetailLoad] = useState(null);
|
const [detailLoad, setDetailLoad] = useState(null);
|
||||||
const [detailRetry, setDetailRetry] = useState(0);
|
const [detailRetry, setDetailRetry] = useState(0);
|
||||||
const retryTenderDetail = () => setDetailRetry(value => value + 1);
|
const retryTenderDetail = () => setDetailRetry(value => value + 1);
|
||||||
@@ -288,7 +297,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
// Canlı ihalede detay açıldığında ilan metnini API'den tamamla
|
// Canlı ihalede detay açıldığında ilan metnini API'den tamamla
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = activeTenderDetail;
|
const t = activeTenderDetail;
|
||||||
if (!t || !t.id?.startsWith('ilan-') || t.aciklama) { setDetailLoad(null); return; }
|
if (!t || !(t.id?.startsWith('ilan-') || t.id?.startsWith('ekap-')) || t.aciklama) { setDetailLoad(null); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setDetailLoad({id:t.id,status:'loading'});
|
setDetailLoad({id:t.id,status:'loading'});
|
||||||
@@ -744,6 +753,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
addSavedFilter,
|
addSavedFilter,
|
||||||
deleteSavedFilter,
|
deleteSavedFilter,
|
||||||
activeTenderDetail,
|
activeTenderDetail,
|
||||||
|
openSearchTender,
|
||||||
detailLoad,
|
detailLoad,
|
||||||
retryTenderDetail,
|
retryTenderDetail,
|
||||||
setActiveTenderDetail,
|
setActiveTenderDetail,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Keep unknown fields unknown when promoting a document-search result to a detail. */
|
||||||
|
export function searchTender(result) {
|
||||||
|
const ikn=String(result.ikn || '').trim();
|
||||||
|
if(!/^\d{4}\/\d+$/.test(ikn)) throw new Error('Bu sonuç için geçerli EKAP İKN bilgisi yok.');
|
||||||
|
const match=String(result.ihaleTarihSaat || '').trim().match(/^(\d{2}\.\d{2}\.\d{4})(?:\s+(\d{2}:\d{2}))?/);
|
||||||
|
return {id:`ekap-${ikn}`,ikn,baslik:result.baslik || '',kurum:result.kurum || '',sehir:result.sehir || '',
|
||||||
|
tarih:match?.[1] || '',saat:match?.[2] || '',alimTuru:result.tur || '',usul:result.usul || '',
|
||||||
|
durum:result.durum || '',items:[],aciklama:'',kaynak:'EKAP ürün araması',
|
||||||
|
sourceDate:result.ihaleTarihSaat || '',discoveredFromSearch:true};
|
||||||
|
}
|
||||||
@@ -20,10 +20,17 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
|
|||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
|
const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tenders/${encodeURIComponent(tenderId)}/detail`, {signal:controller.signal});
|
const searchResult=String(tenderId).startsWith('ekap-');
|
||||||
|
const url=searchResult ? `/api/documents/ilan?ikn=${encodeURIComponent(tenderId.slice(5))}` : `/api/tenders/${encodeURIComponent(tenderId)}/detail`;
|
||||||
|
const res = await fetch(url, {signal:controller.signal});
|
||||||
if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
|
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
|
||||||
|
if (searchResult) {
|
||||||
|
if (data.ikn !== tenderId.slice(5)) throw new Error('Kaynak yanıtındaki İKN aranan ihaleyle uyuşmuyor.');
|
||||||
|
data.aciklama=data.metin;
|
||||||
|
data.sourceWarning=data.ilanSayisi ? null : 'EKAP ilan metni bulunamadı; yalnız temel ihale bilgileri alındı. Resmi dokümanı kontrol edin.';
|
||||||
|
}
|
||||||
if (typeof data.aciklama !== 'string' || !data.aciklama.trim())
|
if (typeof data.aciklama !== 'string' || !data.aciklama.trim())
|
||||||
throw new Error('Kaynak ilan metni alınamadı. Yeniden deneyin veya asıl ilanı kontrol edin.');
|
throw new Error('Kaynak ilan metni alınamadı. Yeniden deneyin veya asıl ilanı kontrol edin.');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {test} from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {searchTender} from '../src/services/searchTender.js';
|
||||||
|
test('search-only tender becomes a stable detail without fabricated bid or classification fields',()=>{
|
||||||
|
const source={ikn:'2026/1538773',baslik:'Laboratuvar hizmeti',tur:'Hizmet Alımı',ihaleTarihSaat:'22.09.2026 11:30'};
|
||||||
|
const t=searchTender(source);
|
||||||
|
assert.equal(t.id,'ekap-2026/1538773');assert.equal(t.alimTuru,'Hizmet Alımı');
|
||||||
|
assert.equal(t.tarih,'22.09.2026');assert.equal(t.saat,'11:30');assert.deepEqual(t.items,[]);
|
||||||
|
const unknown=searchTender({...source,ihaleTarihSaat:'bilinmiyor'});
|
||||||
|
assert.equal(unknown.tarih,'');assert.equal(unknown.saat,'');assert.equal(unknown.aciklama,'');
|
||||||
|
assert.throws(()=>searchTender({...source,ikn:'unknown'}));
|
||||||
|
});
|
||||||
@@ -20,3 +20,17 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c
|
|||||||
await assert.rejects(()=>pending,{name:'AbortError'});
|
await assert.rejects(()=>pending,{name:'AbortError'});
|
||||||
} finally { globalThis.fetch=original; }
|
} finally { globalThis.fetch=original; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('search-only detail uses IKN endpoint and warns when official announcement is absent',async()=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
try {
|
||||||
|
globalThis.fetch=async url=>{
|
||||||
|
assert.equal(url,'/api/documents/ilan?ikn=2026%2F123');
|
||||||
|
return Response.json({success:true,ikn:'2026/123',metin:'Temel ihale bilgisi',ilanSayisi:0});
|
||||||
|
};
|
||||||
|
const detail=await fetchTenderDetail('ekap-2026/123');
|
||||||
|
assert.equal(detail.aciklama,'Temel ihale bilgisi');assert.match(detail.sourceWarning,/yalnız temel/);
|
||||||
|
globalThis.fetch=async()=>Response.json({success:true,ikn:'2026/999',metin:'Farklı kaynak'});
|
||||||
|
await assert.rejects(fetchTenderDetail('ekap-2026/123'),/İKN/);
|
||||||
|
} finally {globalThis.fetch=original;}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user