fix: expose tender detail loading errors and retry

This commit is contained in:
2026-09-06 09:23:13 +03:00
parent a34b758430
commit e07be66e7f
5 changed files with 73 additions and 15 deletions
+7
View File
@@ -241,3 +241,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont
- Başlık/kurum/İKN/kalem araması ortak normalizasyon kullanıyor: Türkçe büyük-küçük harf, birleşik Unicode karakterleri, noktasız ı ve fazla boşluklar aynı şekilde ele alınıyor. Metin ve nesne biçimindeki kalem özetleri destekleniyor. - Başlık/kurum/İKN/kalem araması ortak normalizasyon kullanıyor: Türkçe büyük-küçük harf, birleşik Unicode karakterleri, noktasız ı ve fazla boşluklar aynı şekilde ele alınıyor. Metin ve nesne biçimindeki kalem özetleri destekleniyor.
- Tam kalem havuzu eşleşmeleri artık üretildikleri sorgu ile saklanıyor. Kullanıcı yeni sorguya geçtiği render anında önceki sorgunun eşleşme haritası kullanılmıyor; debounce/ağ beklerken eski sonuçlar yeni aramaya karışmıyor. Mevcut geç yanıt iptal kontrolü korunuyor. - Tam kalem havuzu eşleşmeleri artık üretildikleri sorgu ile saklanıyor. Kullanıcı yeni sorguya geçtiği render anında önceki sorgunun eşleşme haritası kullanılmıyor; debounce/ağ beklerken eski sonuçlar yeni aramaya karışmıyor. Mevcut geç yanıt iptal kontrolü korunuyor.
- Kanıt: Türkçe alan eşleşmeleri ve hemogram → mikroskop sorgu geçişi regresyonlarıyla 65/65 test ve build geçti. Gerçek arama sağlayıcısının tüm eşanlamlı/tıbbi ürün kapsamı ve tarayıcıda geciktirilmiş ağ kabulü henüz doğrulanmadı. - Kanıt: Türkçe alan eşleşmeleri ve hemogram → mikroskop sorgu geçişi regresyonlarıyla 65/65 test ve build geçti. Gerçek arama sağlayıcısının tüm eşanlamlı/tıbbi ürün kapsamı ve tarayıcıda geciktirilmiş ağ kabulü henüz doğrulanmadı.
### D01 / D05 — Detay yükleme hatası ve yeniden deneme
- İlan detayını yüklerken boş kaynak metni artık sessiz başarı/boş ekran olarak bırakılmıyor. Yükleniyor, hata ve yeniden deneme durumları detay penceresinde gösteriliyor.
- Detay isteğine 30 saniye zaman aşımı ve iptal sinyali eklendi; pencere kapatıldığında/ihale değiştiğinde istek durduruluyor ve geç yanıt önceki kayda uygulanmıyor.
- Kaynak detayının kullanıcıya özel görev/not/taslak alanlarını ezmemesi için yanıt publicTender süzgecinden geçiriliyor.
- Kanıt: boş başarılı yanıt, sonraki başarılı deneme, HTTP 503, zaman aşımı ve iptal regresyonuyla 66/66 test ve build geçti. Gerçek kaynakta hata→yeniden deneme tarayıcı kabulü ve tüm kaynak türlerinin detay akışları ayrıca açık.
+6 -1
View File
@@ -13,7 +13,7 @@ import { tenderStateKey } from '../services/tenderState';
import { createTenderICS } from '../services/tenderCalendar'; import { createTenderICS } from '../services/tenderCalendar';
export const TenderDetailModal = () => { export const TenderDetailModal = () => {
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser } = useApp(); const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser, detailLoad, retryTenderDetail } = useApp();
const dialogRef = useRef(null); const dialogRef = useRef(null);
const dialogTitleId = useId(); const dialogTitleId = useId();
const isOpen = Boolean(activeTenderDetail); const isOpen = Boolean(activeTenderDetail);
@@ -105,6 +105,11 @@ 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>}
{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>
<button type="button" onClick={retryTenderDetail} className="mt-2 underline font-bold">İlan metnini yeniden yükle</button>
</div>}
{savingCount > 0 && <p role="status" className="p-2 bg-blue-50">Kaydediliyor…</p>} {savingCount > 0 && <p role="status" className="p-2 bg-blue-50">Kaydediliyor…</p>}
{/* Top Header Controls */} {/* Top Header Controls */}
<div className="bg-[#1f2937] text-white px-6 py-3.5 flex items-center justify-between border-b border-slate-700 shrink-0"> <div className="bg-[#1f2937] text-white px-6 py-3.5 flex items-center justify-between border-b border-slate-700 shrink-0">
+17 -8
View File
@@ -281,26 +281,33 @@ export const AppProvider = ({ children }) => {
}, []); }, []);
const [activeTenderDetail, setActiveTenderDetail] = useState(null); const [activeTenderDetail, setActiveTenderDetail] = useState(null);
const [detailLoad, setDetailLoad] = useState(null);
const [detailRetry, setDetailRetry] = useState(0);
const retryTenderDetail = () => setDetailRetry(value => value + 1);
// 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) return; if (!t || !t.id?.startsWith('ilan-') || t.aciklama) { setDetailLoad(null); return; }
let cancelled = false; let cancelled = false;
fetchTenderDetail(t.id) const controller = new AbortController();
setDetailLoad({id:t.id,status:'loading'});
fetchTenderDetail(t.id, {signal:controller.signal})
.then(detail => { .then(detail => {
if (cancelled || !detail.aciklama) return; if (cancelled) return;
const source = publicTender(detail);
setDetailLoad({id:t.id,status:'loaded'});
setActiveTenderDetail(prev => setActiveTenderDetail(prev =>
prev && prev.id === t.id ? { ...prev, ...detail } : prev prev && prev.id === t.id ? { ...prev, ...source } : prev
); );
setTenders(prev => setTenders(prev =>
prev.map(x => (x.id === t.id ? { ...x, ...detail } : x)) prev.map(x => (x.id === t.id ? { ...x, ...source } : x))
); );
}) })
.catch(() => {}); .catch(error => { if (!cancelled) setDetailLoad({id:t.id,status:'error',error:error.message}); });
return () => { cancelled = true; }; return () => { cancelled = true; controller.abort(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTenderDetail?.id]); }, [activeTenderDetail?.id, detailRetry]);
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
@@ -737,6 +744,8 @@ export const AppProvider = ({ children }) => {
addSavedFilter, addSavedFilter,
deleteSavedFilter, deleteSavedFilter,
activeTenderDetail, activeTenderDetail,
detailLoad,
retryTenderDetail,
setActiveTenderDetail, setActiveTenderDetail,
isDetailedSearchOpen, isDetailedSearchOpen,
setIsDetailedSearchOpen, setIsDetailedSearchOpen,
+21 -6
View File
@@ -12,12 +12,27 @@ export async function fetchLiveTenders({ refresh = false } = {}) {
return data; return data;
} }
export async function fetchTenderDetail(tenderId) { export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} = {}) {
const res = await fetch(`/api/tenders/${tenderId}/detail`); const controller = new AbortController();
if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`); const cancel = () => controller.abort();
const data = await res.json(); if (signal?.aborted) cancel();
if (!data.success) throw new Error(data.error || 'Detay alınamadı'); else signal?.addEventListener('abort',cancel,{once:true});
return data; let timedOut = false;
const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
try {
const res = await fetch(`/api/tenders/${encodeURIComponent(tenderId)}/detail`, {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 (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;
} catch (error) {
if (timedOut) throw new Error('İlan metni yüklenirken zaman aşımı oluştu. Yeniden deneyin.');
throw error;
} finally {
clearTimeout(timer); signal?.removeEventListener('abort',cancel);
}
} }
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir. // Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {fetchTenderDetail} from '../src/services/tenderApi.js';
test('detail loading rejects empty source, supports retry and stops on timeout/cancel',async()=>{
const original=globalThis.fetch;
try {
globalThis.fetch=async()=>new Response(JSON.stringify({success:true,aciklama:''}));
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/Kaynak ilan metni alınamadı/);
globalThis.fetch=async()=>new Response(JSON.stringify({success:true,aciklama:'Tam ilan metni'}));
assert.equal((await fetchTenderDetail('ilan-1')).aciklama,'Tam ilan metni');
globalThis.fetch=async()=>new Response('error',{status:503});
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/);
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
});
await assert.rejects(()=>fetchTenderDetail('ilan-1',{timeoutMs:5}),/zaman aşımı/);
const controller=new AbortController();
const pending=fetchTenderDetail('ilan-1',{signal:controller.signal});controller.abort();
await assert.rejects(()=>pending,{name:'AbortError'});
} finally { globalThis.fetch=original; }
});