feat: canlı veri — ilan.gov.tr açık ihale + EKAP doğrudan temin (22/d)
- server/index.js: ilan.gov.tr proxy (açık ihale + kira/satış), TLS zincir düzeltmesi - server/dt-worker.mjs: EKAP doğrudan temin toplayıcı (Playwright, imzasız oturum içi fetch) - server/dt-store.mjs + il-plaka.mjs: DT verisini tender modeline çevirme - src/services/tenderApi.js + AppContext: mock veri yerine canlı API - Doğrudan Temin sekmesi 256 gerçek 22/d kaydıyla dolu
This commit is contained in:
@@ -91,10 +91,17 @@ export const TenderCard = ({ tender }) => {
|
||||
<span>{tender.aciliyetBadge}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-blue-600" />
|
||||
<span>%{tender.aiMatchScore} Uyumlu</span>
|
||||
</span>
|
||||
{tender.aiMatchScore != null && (
|
||||
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-blue-600" />
|
||||
<span>%{tender.aiMatchScore} Uyumlu</span>
|
||||
</span>
|
||||
)}
|
||||
{tender.kaynak && (
|
||||
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-[11px] font-bold px-2 py-1 rounded-md">
|
||||
🟢 {tender.kaynak}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Material Items Preview Box */}
|
||||
|
||||
@@ -433,22 +433,26 @@ END:VCALENDAR`;
|
||||
<p className="text-xs text-blue-200">Teknik şartname ve geçmiş ihale sonuçları analiz edildi.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bg-amber-400 text-slate-900 font-black text-xs px-3 py-1.5 rounded-xl shadow-xs">
|
||||
%{tender.aiMatchScore} Şirket Uyumu
|
||||
</span>
|
||||
{tender.aiMatchScore != null && (
|
||||
<span className="bg-amber-400 text-slate-900 font-black text-xs px-3 py-1.5 rounded-xl shadow-xs">
|
||||
%{tender.aiMatchScore} Şirket Uyumu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
<span className="text-slate-300 text-xs font-semibold block">Risk Skoru</span>
|
||||
<span className="text-lg font-black text-emerald-400 flex items-center gap-1">
|
||||
<ShieldCheck className="w-5 h-5" /> {tender.aiRiskScore} Risk
|
||||
<ShieldCheck className="w-5 h-5" /> {tender.aiRiskScore || 'Analiz Bekliyor'}{tender.aiRiskScore ? ' Risk' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
<span className="text-slate-300 text-xs font-semibold block">Önerilen Teklif Aralığı</span>
|
||||
<span className="text-base font-black text-amber-300">
|
||||
₺{tender.aiBidRange?.recommended.toLocaleString('tr-TR')}
|
||||
{tender.aiBidRange?.recommended != null
|
||||
? `₺${tender.aiBidRange.recommended.toLocaleString('tr-TR')}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
|
||||
+44
-53
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
|
||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
|
||||
const AppContext = createContext();
|
||||
|
||||
@@ -30,68 +31,56 @@ export const AppProvider = ({ children }) => {
|
||||
|
||||
const [isLiveMode, setIsLiveMode] = useState(true);
|
||||
const [isRefreshingLive, setIsRefreshingLive] = useState(false);
|
||||
const [liveDataError, setLiveDataError] = useState(null);
|
||||
const [liveDataSource, setLiveDataSource] = useState(null);
|
||||
|
||||
const refreshLiveEKAPData = () => {
|
||||
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
||||
const refreshLiveEKAPData = async ({ refresh = true } = {}) => {
|
||||
setIsRefreshingLive(true);
|
||||
setTimeout(() => {
|
||||
const liveItems = [
|
||||
{
|
||||
id: `live-ekap-${Date.now()}`,
|
||||
ikn: `2026/${Math.floor(100000 + Math.random() * 900000)}`,
|
||||
baslik: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ 18 KALEM TIBBİ SARF MALZEME ALIMI",
|
||||
kurum: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ BAŞHEKİMLİĞİ",
|
||||
sehir: "Ankara",
|
||||
bolge: "İÇ ANADOLU BÖLGESİ",
|
||||
tarih: "14.08.2026",
|
||||
saat: "10:30",
|
||||
alimTuru: "Açık İhale",
|
||||
usul: "Açık İhale Usulü (4734 Sayılı Kanun Madde 19)",
|
||||
sektor: "Tıbbi Cihaz ve Sarf Malzemeleri",
|
||||
brans: "Ortopedi & Beyin Cerrahi Sarf Malzemeleri",
|
||||
kurumTipi: "Eğitim ve Araştırma Hastaneleri",
|
||||
eklenmeZamani: " Canlı Akış (Canlı Yayınlandı)",
|
||||
isYeni: true,
|
||||
isLiveFeed: true,
|
||||
aciliyetBadge: "🔴 CANLI İHALE",
|
||||
items: [
|
||||
{ id: "li1", ad: "Titanyum Pedikül Vidası", miktar: "150", birim: "adet", birimFiyat: 1420 },
|
||||
{ id: "li2", ad: "Cerrahi Rod (5.5mm)", miktar: "80", birim: "adet", birimFiyat: 680 },
|
||||
{ id: "li3", ad: "Posterior Servikal Kafes", miktar: "40", birim: "adet", birimFiyat: 2450 }
|
||||
],
|
||||
aciklama: "T.C. Sağlık Bakanlığı Ankara Şehir Hastanesi Beyin ve Sinir Cerrahisi Kliniği İhtiyacı İçin Tıbbi Sarf Malzeme Canlı Veri Akışıyla Alınacaktır.",
|
||||
yaklasikMaliyet: 542000,
|
||||
teslimatSuresi: "15 Takvim Günü",
|
||||
teslimYeri: "Ankara Bilkent Şehir Hastanesi Medikal Depo",
|
||||
yeterlikKriterleri: ["ÜTS Barkod Kaydı", "ISO 13485 Kalite Belgesi", "TSE HYB Satış Yetkisi"],
|
||||
aiMatchScore: 98,
|
||||
aiRiskScore: "Düşük",
|
||||
aiBidRange: { min: 435000, max: 510000, recommended: 468000 },
|
||||
kanbanStatus: "incelenecek",
|
||||
atananPersonel: "Hikmet Tanrıverdi",
|
||||
durum: "Aktif",
|
||||
incelendi: false,
|
||||
takipte: true,
|
||||
sozlesmeBedeli: null,
|
||||
kazananFirma: null
|
||||
}
|
||||
];
|
||||
|
||||
setTenders(prev => {
|
||||
const filtered = prev.filter(t => !t.id.startsWith('live-ekap-'));
|
||||
return [...liveItems, ...filtered];
|
||||
});
|
||||
|
||||
setLiveDataError(null);
|
||||
try {
|
||||
const data = await fetchLiveTenders({ refresh });
|
||||
setTenders(prev => mergeUserState(data.tenders, prev));
|
||||
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
|
||||
setLiveStats(prev => ({
|
||||
...prev,
|
||||
todayAdded: prev.todayAdded + 1,
|
||||
totalItems: prev.totalItems + 1
|
||||
bugunEklenen: data.todayCount ?? prev.bugunEklenen,
|
||||
aktifTakip: data.totalCount ?? prev.aktifTakip
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
setLiveDataError(err.message);
|
||||
} finally {
|
||||
setIsRefreshingLive(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Açılışta canlı veriyi yükle (başarısız olursa mevcut/mock veri ekranda kalır)
|
||||
useEffect(() => {
|
||||
refreshLiveEKAPData({ refresh: false });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
||||
|
||||
// Canlı ihalede detay açıldığında ilan metnini API'den tamamla
|
||||
useEffect(() => {
|
||||
const t = activeTenderDetail;
|
||||
if (!t || !t.id?.startsWith('ilan-') || t.aciklama) return;
|
||||
let cancelled = false;
|
||||
fetchTenderDetail(t.id)
|
||||
.then(detail => {
|
||||
if (cancelled || !detail.aciklama) return;
|
||||
setActiveTenderDetail(prev =>
|
||||
prev && prev.id === t.id ? { ...prev, aciklama: detail.aciklama } : prev
|
||||
);
|
||||
setTenders(prev =>
|
||||
prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : x))
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTenderDetail?.id]);
|
||||
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
|
||||
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
|
||||
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
||||
@@ -317,6 +306,8 @@ export const AppProvider = ({ children }) => {
|
||||
setIsLiveMode,
|
||||
isRefreshingLive,
|
||||
refreshLiveEKAPData,
|
||||
liveDataError,
|
||||
liveDataSource,
|
||||
liveStats,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ihaleist canlı veri istemcisi.
|
||||
* Backend proxy (server/index.js) üzerinden ilan.gov.tr resmi ihale ilanlarını çeker.
|
||||
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
|
||||
*/
|
||||
|
||||
export async function fetchLiveTenders({ refresh = false } = {}) {
|
||||
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
|
||||
if (!res.ok) throw new Error(`API hatası: HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü');
|
||||
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;
|
||||
}
|
||||
|
||||
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
|
||||
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel'];
|
||||
|
||||
export function mergeUserState(liveTenders, previousTenders) {
|
||||
const prevById = new Map((previousTenders || []).map((t) => [t.id, t]));
|
||||
return liveTenders.map((tender) => {
|
||||
const prev = prevById.get(tender.id);
|
||||
if (!prev) return tender;
|
||||
const merged = { ...tender };
|
||||
for (const f of USER_FIELDS) {
|
||||
if (prev[f] !== undefined && prev[f] !== null && prev[f] !== false) merged[f] = prev[f];
|
||||
}
|
||||
if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama;
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user