fix: expose tender detail loading errors and retry
This commit is contained in:
@@ -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.
|
||||
- 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ı.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -13,7 +13,7 @@ import { tenderStateKey } from '../services/tenderState';
|
||||
import { createTenderICS } from '../services/tenderCalendar';
|
||||
|
||||
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 dialogTitleId = useId();
|
||||
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]">
|
||||
|
||||
{(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>}
|
||||
{/* 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">
|
||||
|
||||
@@ -281,26 +281,33 @@ export const AppProvider = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
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;
|
||||
fetchTenderDetail(t.id)
|
||||
const controller = new AbortController();
|
||||
setDetailLoad({id:t.id,status:'loading'});
|
||||
fetchTenderDetail(t.id, {signal:controller.signal})
|
||||
.then(detail => {
|
||||
if (cancelled || !detail.aciklama) return;
|
||||
if (cancelled) return;
|
||||
const source = publicTender(detail);
|
||||
setDetailLoad({id:t.id,status:'loaded'});
|
||||
setActiveTenderDetail(prev =>
|
||||
prev && prev.id === t.id ? { ...prev, ...detail } : prev
|
||||
prev && prev.id === t.id ? { ...prev, ...source } : prev
|
||||
);
|
||||
setTenders(prev =>
|
||||
prev.map(x => (x.id === t.id ? { ...x, ...detail } : x))
|
||||
prev.map(x => (x.id === t.id ? { ...x, ...source } : x))
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
.catch(error => { if (!cancelled) setDetailLoad({id:t.id,status:'error',error:error.message}); });
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTenderDetail?.id]);
|
||||
}, [activeTenderDetail?.id, detailRetry]);
|
||||
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
|
||||
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
|
||||
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
||||
@@ -737,6 +744,8 @@ export const AppProvider = ({ children }) => {
|
||||
addSavedFilter,
|
||||
deleteSavedFilter,
|
||||
activeTenderDetail,
|
||||
detailLoad,
|
||||
retryTenderDetail,
|
||||
setActiveTenderDetail,
|
||||
isDetailedSearchOpen,
|
||||
setIsDetailedSearchOpen,
|
||||
|
||||
@@ -12,12 +12,27 @@ export async function fetchLiveTenders({ refresh = false } = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenderDetail(tenderId) {
|
||||
const res = await fetch(`/api/tenders/${tenderId}/detail`);
|
||||
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ı');
|
||||
return data;
|
||||
export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} = {}) {
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort();
|
||||
if (signal?.aborted) cancel();
|
||||
else signal?.addEventListener('abort',cancel,{once:true});
|
||||
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.
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
Reference in New Issue
Block a user