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.
|
- 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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
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}`);
|
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 (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;
|
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.
|
||||||
|
|||||||
@@ -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