From 920a147126d14878a1cb5fa445b3cfd3c13370c7 Mon Sep 17 00:00:00 2001 From: ht Date: Wed, 2 Sep 2026 13:02:40 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20ust=20serit=20tiklaninca=20donuyordu=20?= =?UTF-8?q?=E2=80=94=20Kanban=20havuzdaki=20tum=20ihaleleri=20(8=20bin+=20?= =?UTF-8?q?kart)=20ciziyordu;=20artik=20yalnizca=20takipteki/kolona=20tasi?= =?UTF-8?q?nan=20ihaleler,=20sekme=20gecisi=20transition=20ile=20aninda=20?= =?UTF-8?q?vurgulaniyor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KanbanBoard: sunucu her ilana varsayilan 'incelenecek' verdigi icin bos pano 8.372 kart basiyordu; yalnizca takipte veya kolona tasinmis ihaleler kaliyor - TopNavigation: useTransition + spinner; agir sekmelerde tiklama karsiliksiz kalmiyor - sunucu: 'yeni liste eskisinden kucukse alma' kurali havuzu 30 Agustos zirvesinde dondurmustu (gunlerce yeni ilan yok); eksiksiz inen liste her zaman devrede - sunucu: isYeni/bugun eklenen UTC-TR gun farkindan hep 0'di; Europe/Istanbul gunune gore ve her yanitta tazeleniyor (eklenmeZamani da donmuyor) - ust kartlar: 'bugun eklenenler' tum ilanlari sayiyordu ('once' metnine bakiyordu), 'son giristen beri' sabit 14'tu; sayimlar liste filtresiyle ayni olcut (ihaleTarih.js), arsiv/sonuclanan filtreleri 'sadece acik' kapisina takilmiyor Co-Authored-By: Claude Fable 5.1 --- .claude/launch.json | 6 +++ server/index.js | 63 +++++++++++++++++++++----- src/components/KanbanBoard.jsx | 6 ++- src/components/LiveStatsBar.jsx | 9 +++- src/components/SummaryCards.jsx | 42 ++++++++++++------ src/components/TenderList.jsx | 32 ++++---------- src/components/TopNavigation.jsx | 27 +++++++++--- src/context/AppContext.jsx | 21 +++++++++ src/services/ihaleTarih.js | 76 ++++++++++++++++++++++++++++++++ 9 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 src/services/ihaleTarih.js diff --git a/.claude/launch.json b/.claude/launch.json index df9d8f3..27f2c8b 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"], "port": 3005 + }, + { + "name": "ihaleist-api", + "runtimeExecutable": "node", + "runtimeArgs": ["server/index.js"], + "port": 3001 } ] } diff --git a/server/index.js b/server/index.js index 5b28b41..7fce1d5 100644 --- a/server/index.js +++ b/server/index.js @@ -109,6 +109,33 @@ function relativeTimeTr(iso) { return `${Math.floor(hours / 24)} gün önce eklendi`; } +// Türkiye günü ("yyyy-aa-gg"). Sunucu konteyneri UTC'de çalışıyor; ilan.gov.tr +// yayın saati TR gece yarısı = "…T21:00:00Z" olduğundan UTC'ye göre gün DÜN +// çıkıyor ve "bugün eklenen" hep 0 görünüyordu. +const TR_GUN = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Istanbul', year: 'numeric', month: '2-digit', day: '2-digit', +}); +function trGun(iso) { + if (!iso) return ''; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : TR_GUN.format(d); +} +const bugunTR = () => TR_GUN.format(new Date()); + +// isYeni / eklenmeZamani ilan havuza girerken hesaplanıp donuyordu; havuz +// günlerce yaşayınca "22 saat önce" damgası ve "bugün" bayrağı bayatlıyordu. +// Her yanıtta tazelenir (3 bin kayıt için birkaç ms). +function ilanTazele(t) { + if (!t.isLiveFeed || t.kaynak !== 'ilan.gov.tr') return t; + t.isYeni = Boolean(t.ilanTarihi) && t.ilanTarihi === bugunTR(); + if (t.yayinZamani) t.eklenmeZamani = relativeTimeTr(t.yayinZamani); + return t; +} +const bugunEklenenSayisi = (liste) => { + const bugun = bugunTR(); + return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length; +}; + function daysUntil(ddmmyyyy) { if (!ddmmyyyy) return null; const [d, m, y] = ddmmyyyy.split('.').map(Number); @@ -130,8 +157,7 @@ function mapAd(ad) { let aciliyetBadge = null; if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN'; - const publishedToday = - ad.publishStartDate && new Date(ad.publishStartDate).toDateString() === new Date().toDateString(); + const ilanTarihi = trGun(ad.publishStartDate); return { id: `ilan-${ad.id}`, @@ -151,8 +177,9 @@ function mapAd(ad) { brans: ihaleTuru || 'Genel', kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'), eklenmeZamani: relativeTimeTr(ad.publishStartDate), - ilanTarihi: ad.publishStartDate ? String(ad.publishStartDate).slice(0, 10) : '', - isYeni: Boolean(publishedToday), + yayinZamani: ad.publishStartDate || null, + ilanTarihi, + isYeni: Boolean(ilanTarihi) && ilanTarihi === bugunTR(), isLiveFeed: true, aciliyetBadge, items: [], @@ -259,7 +286,7 @@ async function ilanSayfalari(baslangic, adet) { istekler.push( ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => { console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`); - return { ads: [] }; + return { ads: [], hata: true }; }) ); } @@ -366,20 +393,31 @@ async function arkaPlandaTamamla(numFound, ilkListe) { const yeni = [...ilkListe]; const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE)); let page = ILK_PARTI; + let eksiksiz = true; // her sayfa hatasız indi mi? while (page < gerekliSayfa) { const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page); const sonuclar = await ilanSayfalari(page, grup); + if (sonuclar.some((r) => r.hata)) eksiksiz = false; const ads = sonuclar.flatMap((r) => r.ads || []); - if (!ads.length) break; + if (!ads.length) { eksiksiz = false; break; } const eklenen = ilanlariBirlestir(yeni, ads); if (eklenen.length) yeni.push(...eklenen); page += grup; await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için } - // Yalnızca daha zengin bir liste elde ettiysek devreye al - if (yeni.length >= cache.tenders.length) { + // EKSİKSİZ inen liste her zaman devreye girer — kaynaktaki ihale sayısı + // düşse bile. Eskiden "yeni liste eskisinden küçükse alma" kuralı vardı; + // kaynak 3.372'lik zirveden 3.100'e inince havuz o zirve anında DONDU: + // günlerce yeni ilan eklenmedi, "bugün eklenen" 0 kaldı. Yarım inen liste + // ise yalnızca eldekinden zenginse alınır (kullanıcı eksik listeye düşmesin). + if (eksiksiz || yeni.length >= cache.tenders.length) { + if (yeni.length < cache.tenders.length) { + console.log(`[ihaleist-api] havuz küçüldü: ${cache.tenders.length} → ${yeni.length} (kaynak ${numFound}); güncel liste devrede`); + } cache.tenders = yeni; - cache.todayCount = yeni.filter((t) => t.isYeni).length; + cache.todayCount = bugunEklenenSayisi(yeni); + } else { + console.warn(`[ihaleist-api] yenileme eksik indi (${yeni.length}/${numFound}); eldeki ${cache.tenders.length} ilan korunuyor`); } console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`); havuzKaydet(numFound, cache.tenders.length); @@ -513,7 +551,7 @@ async function getTenders(force = false) { // Yenileme: eldeki dolu listeyi KORU, yenisi arka planda hazırlansın. if (!cache.tenders || cache.tenders.length === 0) { cache.tenders = ilkListe; - cache.todayCount = ilkListe.filter((t) => t.isYeni).length; + cache.todayCount = bugunEklenenSayisi(ilkListe); console.log(`[ihaleist-api] ilk yükleme: ${ilkListe.length} ilan (havuz ${cache.totalCount}) — kalanı arka planda`); } else { console.log(`[ihaleist-api] yenileme başladı (mevcut ${cache.tenders.length} ilan sunulmaya devam ediyor)`); @@ -589,6 +627,9 @@ async function tendersPayload(force = false) { if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker'); if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)'); + for (const t of c.tenders) ilanTazele(t); // isYeni + "x saat önce" gün değişse de doğru + c.todayCount = bugunEklenenSayisi(c.tenders); + return { success: true, source: sources.join(' + '), @@ -1107,7 +1148,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { success: true, toplamIlan: c.totalCount, - bugunEklenen: c.todayCount, + bugunEklenen: bugunEklenenSayisi(c.tenders), yuklenenIlan: c.tenders.length, kaynak: 'ilan.gov.tr', }); diff --git a/src/components/KanbanBoard.jsx b/src/components/KanbanBoard.jsx index a5beeab..e838a1d 100644 --- a/src/components/KanbanBoard.jsx +++ b/src/components/KanbanBoard.jsx @@ -22,8 +22,12 @@ export const KanbanBoard = () => { ...tenders.map((t) => t.atananPersonel).filter(Boolean), ])]; + // Panoda yalnızca takibe alınan ya da bir kolona taşınmış ihaleler durur. + // Sunucu HER ilana varsayılan 'incelenecek' durumu veriyor; onu "panoda" + // saymak 8 bin+ kartı tek seferde çizdirip sekmeyi saniyelerce donduruyordu. const filteredTenders = tenders.filter(t => { - if (!t.takipte && !t.kanbanStatus) return false; + const panoda = t.takipte || (t.kanbanStatus && t.kanbanStatus !== 'incelenecek'); + if (!panoda) return false; if (selectedPersonel && t.atananPersonel !== selectedPersonel) return false; return true; }); diff --git a/src/components/LiveStatsBar.jsx b/src/components/LiveStatsBar.jsx index f374654..2eaaa76 100644 --- a/src/components/LiveStatsBar.jsx +++ b/src/components/LiveStatsBar.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useApp } from '../context/AppContext'; import { Database, PlusCircle, Eye, Layers, Trophy, TrendingDown } from 'lucide-react'; +import { bugunEklendi, acikIhale } from '../services/ihaleTarih'; export const LiveStatsBar = () => { const { liveStats, tenders } = useApp(); @@ -8,6 +9,12 @@ export const LiveStatsBar = () => { const yukluIhale = tenders.length; const takibimdeki = tenders.filter((t) => t.takipte).length; const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0); + // Üst kartla (BUGÜN EKLENENLER) AYNI ölçüt: bugün yayımlanan ve süresi + // geçmemiş ilanlar (ilan.gov.tr + DT). İki bant farklı sayı göstermesin. + const bugun = new Date(); bugun.setHours(0, 0, 0, 0); + const bugunEklenen = tenders.length + ? tenders.filter((t) => acikIhale(t, bugun) && bugunEklendi(t)).length + : liveStats.bugunEklenen; // Havuz sağlığı: kaynak (ilan.gov.tr) sayısı 7 GÜNLÜK ORTALAMANIN çok altındaysa // bunu açıkça söyleriz — kullanıcı "veri mi kayboldu?" diye düşünmesin. @@ -25,7 +32,7 @@ export const LiveStatsBar = () => { const stats = [ { deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true }, { deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' }, - { deger: liveStats.bugunEklenen, etiket: 'Bugün Eklenen', renk: 'text-emerald-400', icon: PlusCircle, iconRenk: 'text-emerald-400' }, + { deger: bugunEklenen, etiket: 'Bugün Eklenen', renk: 'text-emerald-400', icon: PlusCircle, iconRenk: 'text-emerald-400' }, { deger: kalemSayisi, etiket: 'Kalem', renk: 'text-white', icon: Layers, iconRenk: 'text-indigo-400' }, { deger: liveStats.arsiv, etiket: 'Sonuç Arşivi', renk: 'text-white', icon: Trophy, iconRenk: 'text-emerald-400' }, { deger: takibimdeki, etiket: 'Takibimdeki', renk: 'text-white', icon: Eye, iconRenk: 'text-amber-400' }, diff --git a/src/components/SummaryCards.jsx b/src/components/SummaryCards.jsx index 7ffda39..22fa2ad 100644 --- a/src/components/SummaryCards.jsx +++ b/src/components/SummaryCards.jsx @@ -1,49 +1,62 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useApp } from '../context/AppContext'; import { Layers, CalendarPlus, Star, BellRing, EyeOff } from 'lucide-react'; +import { acikIhale, hizliFiltreGecer } from '../services/ihaleTarih'; export const SummaryCards = () => { - const { tenders, quickFilter, setQuickFilter, setActiveTab } = useApp(); + const { tenders, quickFilter, setQuickFilter, setActiveTab, oncekiGiris, favoriteKurumlar } = useApp(); - const activeCount = tenders.filter(t => t.durum === 'Aktif').length; - const todayCount = tenders.filter(t => (t.eklenmeZamani || '').includes('önce') || (t.eklenmeZamani || '').includes('az')).length; - const trackedCount = tenders.filter(t => t.takipte).length; - const sinceLastLoginCount = 14; // Mocked counter - const unreadCount = tenders.filter(t => !t.incelendi).length; + // Sayımlar listedeki filtreyle AYNI ölçütü kullanır (ihaleTarih.js); karta + // tıklayınca gelen liste ile karttaki sayı birbirini tutar. + const sayim = useMemo(() => { + const bugun = new Date(); bugun.setHours(0, 0, 0, 0); + const secenek = { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris }; + const say = (id) => tenders.filter((t) => acikIhale(t, bugun) && hizliFiltreGecer(t, id, secenek)).length; + return { + all: say('all'), + bugun_eklenenler: say('bugun_eklenenler'), + takiptekiler: say('takiptekiler'), + son_giris: say('son_giris'), + incelenmedi: say('incelenmedi'), + }; + }, [tenders, oncekiGiris, favoriteKurumlar]); const cards = [ { id: 'all', label: 'AKTİF İHALELER', - count: activeCount, + count: sayim.all, icon: Layers, color: 'blue' }, { id: 'bugun_eklenenler', label: 'BUGÜN EKLENENLER', - count: todayCount, + count: sayim.bugun_eklenenler, icon: CalendarPlus, color: 'emerald' }, { id: 'takiptekiler', label: 'TAKİP ETTİKLERİM', - count: trackedCount, + count: sayim.takiptekiler, icon: Star, color: 'amber' }, { id: 'son_giris', label: 'SON GİRİŞTEN BERİ', - count: sinceLastLoginCount, + count: sayim.son_giris, icon: BellRing, - color: 'purple' + color: 'purple', + title: oncekiGiris + ? `Önceki ziyaretinizden (${new Date(oncekiGiris).toLocaleString('tr-TR')}) sonra yayımlanan ilanlar` + : 'İlk ziyaretiniz — bugün yayımlanan ilanlar gösteriliyor' }, { id: 'incelenmedi', label: 'İNCELENMEMİŞ', - count: unreadCount, + count: sayim.incelenmedi, icon: EyeOff, color: 'rose' } @@ -58,6 +71,7 @@ export const SummaryCards = () => { return (
{ setQuickFilter(card.id); setActiveTab('list'); @@ -70,7 +84,7 @@ export const SummaryCards = () => { >

{card.label}

-

{card.count}

+

{card.count.toLocaleString('tr-TR')}

String(v || '').trim().toLocaleUpperCase('tr-TR'); -// "gg.aa.yyyy" → Date. Ayrıştırılamayanı GEÇMİŞ SAYMAYIZ: tarihi belirsiz bir -// ihaleyi sessizce gizlemek, geçmiş bir ihaleyi göstermekten daha kötüdür. -const ihaleGunu = (v) => { - const m = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(String(v || '').trim()); - if (!m) return null; - return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])); -}; - export const TenderList = () => { const { tenders, @@ -28,7 +21,8 @@ export const TenderList = () => { quickFilter, onlyValidOnly, setOnlyValidOnly, - favoriteKurumlar + favoriteKurumlar, + oncekiGiris } = useApp(); const [sortBy, setSortBy] = useState('newest'); // 'newest', 'urgent', 'distant' @@ -57,21 +51,11 @@ export const TenderList = () => { // Geçmiş ihaleler: varsayılan olarak GİZLİ. Kullanıcı "geçmişi de göster" // derse (onlyValidOnly=false) hepsi gelir. Tarihi okunamayan kayıt gizlenmez. - if (onlyValidOnly) { - const gun = ihaleGunu(tender.tarih); - if (gun && gun < bugun) return false; - } + // Arşiv / sonuçlananlar gibi geçmişi hedefleyen hızlı filtrelerde kapı kapalı. + if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false; - // Quick Filter - if (quickFilter === 'favori_kurumlar' && !(favoriteKurumlar || []).includes((tender.kurum || '').trim())) return false; - if (quickFilter === 'dogrudan_temin' && tender.alimTuru !== 'Doğrudan Temin') return false; - if (quickFilter === 'bugun_eklenenler' && !((tender.eklenmeZamani || '').includes('önce') || (tender.eklenmeZamani || '').includes('az'))) return false; - if (quickFilter === 'incelenenler' && !tender.incelendi) return false; - if (quickFilter === 'incelenmedi' && tender.incelendi) return false; - if (quickFilter === 'takiptekiler' && !tender.takipte) return false; - if (quickFilter === 'sozlesmeler' && tender.durum !== 'Sözleşme İmzalandı') return false; - if (quickFilter === 'sonuclananlar' && tender.durum !== 'Sonuçlandı') return false; - if (quickFilter === 'iptal' && tender.durum !== 'İptal') return false; + // Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde + if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris })) return false; // Dropdown Filters if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false; @@ -87,7 +71,7 @@ export const TenderList = () => { if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih); return 0; }); - }, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun]); + }, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]); const gorunenTenders = filteredTenders.slice(0, gosterilen); const dahaVar = gosterilen < filteredTenders.length; diff --git a/src/components/TopNavigation.jsx b/src/components/TopNavigation.jsx index f0e23e7..08d12a7 100644 --- a/src/components/TopNavigation.jsx +++ b/src/components/TopNavigation.jsx @@ -1,10 +1,23 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect, useCallback, useTransition } from 'react'; import { useApp } from '../context/AppContext'; -import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; export const TopNavigation = () => { const { activeTab, setActiveTab, isAdmin } = useApp(); + // Ağır sekmeler (10 bin+ kayıtlı havuzda Kanban, analiz vb.) tıklamadan + // sonra saniyelerce hiçbir tepki vermiyordu — kullanıcı "tıklanmıyor" sanıyordu. + // Sekme değişimi geçiş (transition) olarak yapılır: tıklanan sekme ANINDA + // vurgulanır ve dönen simge gösterilir, ağır görünüm arka planda hazırlanır. + const [isPending, startTransition] = useTransition(); + const [hedefTab, setHedefTab] = useState(null); + const sekmeSec = (id) => { + if (id === activeTab) return; + setHedefTab(id); + startTransition(() => setActiveTab(id)); + }; + const bekleyen = isPending ? hedefTab : null; + const navItems = [ { id: 'list', label: 'Ana İhale Listesi', icon: LayoutGrid }, { id: 'bulten', label: 'Günlük Bülten', icon: Newspaper }, @@ -90,19 +103,23 @@ export const TopNavigation = () => {
{navItems.map(item => { const Icon = item.icon; - const isActive = activeTab === item.id; + const isActive = bekleyen ? bekleyen === item.id : activeTab === item.id; + const yukleniyor = bekleyen === item.id; return (