Open and retain tender details discovered outside the main pool

This commit is contained in:
2026-09-06 10:01:34 +03:00
parent 819a10c006
commit a136a74a1b
8 changed files with 66 additions and 6 deletions
+1
View File
@@ -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]">
{(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 === 'error' && <div role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">
<p>{detailLoad.error}</p>
+4 -4
View File
@@ -24,7 +24,7 @@ const gecmisMi = tarihSaat => {
export const UrunAramaModal = () => {
const {
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, openSearchTender, tenders,
urunAramaBaslangic, setUrunAramaBaslangic,
} = useApp();
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="text-slate-400">{t.durum}</span>
{acik && (
{/^\d{4}\/\d+$/.test(String(t.ikn || '')) && (
<button
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
onClick={() => { openSearchTender(t); setIsUrunAramaOpen(false); }}
className="font-bold text-indigo-700 hover:underline"
>
Havuzda açık — detayı aç
{acik ? 'Listedeki detayı aç' : 'İhale detayını aç'}
</button>
)}
</div>
+11 -1
View File
@@ -1,3 +1,4 @@
import { searchTender } from '../services/searchTender';
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
@@ -281,6 +282,14 @@ export const AppProvider = ({ children }) => {
}, []);
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 [detailRetry, setDetailRetry] = useState(0);
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
useEffect(() => {
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;
const controller = new AbortController();
setDetailLoad({id:t.id,status:'loading'});
@@ -744,6 +753,7 @@ export const AppProvider = ({ children }) => {
addSavedFilter,
deleteSavedFilter,
activeTenderDetail,
openSearchTender,
detailLoad,
retryTenderDetail,
setActiveTenderDetail,
+10
View File
@@ -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};
}
+8 -1
View File
@@ -20,10 +20,17 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
let timedOut = false;
const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
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}`);
const data = await res.json();
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())
throw new Error('Kaynak ilan metni alınamadı. Yeniden deneyin veya asıl ilanı kontrol edin.');
return data;