fix: order calendar deadlines and surface undated tenders
This commit is contained in:
@@ -997,3 +997,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Hata listesi e-posta alanına yönlendirir; alan altında açıklama, aria-invalid ve aria-describedby bağlantısı bulunur. E-posta ve telefon alanları uygun HTML input türlerini kullanır. Geçersiz taslak yine kaydedilebilir; hazır çıktı kapısı açılmaz.
|
- Hata listesi e-posta alanına yönlendirir; alan altında açıklama, aria-invalid ve aria-describedby bağlantısı bulunur. E-posta ve telefon alanları uygun HTML input türlerini kullanır. Geçersiz taslak yine kaydedilebilir; hazır çıktı kapısı açılmaz.
|
||||||
- 226 otomatik test ve derleme geçti. Hatalı adresler, baş/son boşluklar, opsiyonel sicil alanı, zorunlu alanlarda nesne/dizi/boşluk ve hazırlık özetiyle aynı hata hedefi sınandı.
|
- 226 otomatik test ve derleme geçti. Hatalı adresler, baş/son boşluklar, opsiyonel sicil alanı, zorunlu alanlarda nesne/dizi/boşluk ve hazırlık özetiyle aynı hata hedefi sınandı.
|
||||||
- Bu biçim/tamlık kontrolüdür; vergi kaydı, yetki, telefonun ulaşılabilirliği veya e-posta sahipliği doğrulaması değildir. Gerçek firma uygunluğu ve yeni alan hata görünümünün tarayıcı kabulü açık. Canlıya yayın yapılmadı.
|
- Bu biçim/tamlık kontrolüdür; vergi kaydı, yetki, telefonun ulaşılabilirliği veya e-posta sahipliği doğrulaması değildir. Gerçek firma uygunluğu ve yeni alan hata görünümünün tarayıcı kabulü açık. Canlıya yayın yapılmadı.
|
||||||
|
|
||||||
|
### Takvim gündeminde saat sırası ve belirsiz tarih görünümü — 6 Eylül 2026
|
||||||
|
- Aynı gündeki ihaleler artık bilinen son teklif saatine göre artan sırada; saati bilinmeyenler sonda gösterilir. Eşit saatlerde İKN/kimlik sırası kullanılır. Sayımlar için havuz her gün yeniden taranmak yerine tek geçişte günlere ayrılır.
|
||||||
|
- Tarihi eksik veya geçersiz kayıtlar önceden takvimde tamamen kayboluyordu. Tarihi doğrulanacak ihaleler bölümünde kaynak detayına erişilebilir biçimde gösterilir; bunlara gün/saat uydurulmaz. Mevcut iptal/sonuç durumu ve son teklif zamanı etiketi kartta görünür.
|
||||||
|
- Gün seçiminde aria-pressed ve tam tarihli erişilebilir ad eklendi. İhale kartları klavye davranışı elle yazılmış div yerine doğal button oldu. Mevcut ay seçicisi korunur.
|
||||||
|
- 227 otomatik test ve derleme geçti. Karışık saat sırası, bilinmeyen saat, geçersiz/eksik tarihin kaybolmaması, geçmiş kayıt ve girdi sırasının değiştirilmemesi test edildi.
|
||||||
|
- Yeni takvim kartlarının tarayıcı/mobil kabulü ve Google/Outlook aktarım kabulü açık. Bu değişiklik ICS aktarımını test etmiş sayılmaz. Canlıya yayın yapılmadı.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useDeadlineClock } from '../../hooks/useDeadlineClock';
|
import { useDeadlineClock } from '../../hooks/useDeadlineClock';
|
||||||
import { calendarMonthDays, istanbulDateKey, tenderDateKey, deadlineInfo } from '../../services/tenderCalendar';
|
import { calendarMonthDays, istanbulDateKey, calendarAgenda } from '../../services/tenderCalendar';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CalendarDays } from 'lucide-react';
|
||||||
|
|
||||||
export const TenderCalendar = () => {
|
export const TenderCalendar = () => {
|
||||||
const now = useDeadlineClock();
|
const now = useDeadlineClock();
|
||||||
@@ -10,8 +10,22 @@ export const TenderCalendar = () => {
|
|||||||
const today = istanbulDateKey(now);
|
const today = istanbulDateKey(now);
|
||||||
const [month,setMonth] = useState(today.slice(0,7));
|
const [month,setMonth] = useState(today.slice(0,7));
|
||||||
const [selectedDay, setSelectedDay] = useState(today);
|
const [selectedDay, setSelectedDay] = useState(today);
|
||||||
const daysList = calendarMonthDays(month).map(day => ({...day,count:tenders.filter(t=>tenderDateKey(t)===day.key).length}));
|
const agenda=useMemo(()=>calendarAgenda(tenders,now),[tenders,now]);
|
||||||
const dayTenders = tenders.filter(t => tenderDateKey(t) === selectedDay);
|
const daysList=calendarMonthDays(month).map(day=>({...day,count:agenda.days.get(day.key)?.length || 0}));
|
||||||
|
const dayTenders=agenda.days.get(selectedDay) || [];
|
||||||
|
const card=({tender:t,info})=><button key={t.id} type="button" onClick={()=>setActiveTenderDetail(t)}
|
||||||
|
className="w-full text-left bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-all flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 space-y-1">
|
||||||
|
<span className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="bg-purple-100 text-purple-900 rounded px-2 py-1">{info.deadline?.toLocaleTimeString('tr-TR',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Istanbul'}) || (info.dateKey ? 'Saat doğrulanmalı' : 'Tarih doğrulanmalı')}</span>
|
||||||
|
<span>#{t.ikn}</span>
|
||||||
|
<span>{t.durum || ''}{t.durum ? ' · ' : ''}{info.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="block font-bold text-sm break-words">{t.baslik}</span>
|
||||||
|
<span className="block text-xs text-slate-500 break-words">{t.kurum} · {t.sehir}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-amber-100 text-amber-900 rounded px-2 py-1">{t.alimTuru}</span>
|
||||||
|
</button>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 my-4 animate-fade-in">
|
<div className="space-y-4 my-4 animate-fade-in">
|
||||||
@@ -38,6 +52,8 @@ export const TenderCalendar = () => {
|
|||||||
{daysList.map(d => (
|
{daysList.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.key}
|
key={d.key}
|
||||||
|
aria-pressed={selectedDay===d.key}
|
||||||
|
aria-label={`${d.date} ${d.label}, ${d.count} ihale`}
|
||||||
onClick={() => setSelectedDay(d.key)}
|
onClick={() => setSelectedDay(d.key)}
|
||||||
className={`min-w-0 p-3 rounded-xl border text-center transition-all cursor-pointer ${
|
className={`min-w-0 p-3 rounded-xl border text-center transition-all cursor-pointer ${
|
||||||
selectedDay === d.key
|
selectedDay === d.key
|
||||||
@@ -60,31 +76,15 @@ export const TenderCalendar = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-bold">{selectedDay.split('-').reverse().join('.')} tarihli ihaleler</h2>
|
<h2 className="text-sm font-bold">{selectedDay.split('-').reverse().join('.')} tarihli ihaleler</h2>
|
||||||
{!dayTenders.length && <p className="text-sm text-slate-500">Bu gün için yüklenen havuzda ihale yok.</p>}
|
{!dayTenders.length && <p className="text-sm text-slate-500">Bu gün için yüklenen havuzda ihale yok.</p>}
|
||||||
{dayTenders.map(t => (
|
<p className="text-xs text-slate-500">Saati bilinen ihaleler erken saatten geç saate sıralanır; saati doğrulanmamış kayıtlar sonda gösterilir.</p>
|
||||||
<div
|
{dayTenders.map(card)}
|
||||||
key={t.id}
|
|
||||||
role="button" tabIndex={0} onKeyDown={e=>{if(e.key==='Enter' || e.key===' '){e.preventDefault();setActiveTenderDetail(t);}}}
|
|
||||||
onClick={() => setActiveTenderDetail(t)}
|
|
||||||
className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md cursor-pointer transition-all flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="bg-purple-100 text-purple-900 text-[10px] font-extrabold px-2 py-0.5 rounded">
|
|
||||||
⏰ {deadlineInfo(t, now).deadline?.toLocaleTimeString('tr-TR',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Istanbul'}) || 'Saat doğrulanmalı'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400 font-mono">#{t.ikn}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-bold text-sm text-slate-900 hover:text-purple-600">{t.baslik}</h3>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">🏢 {t.kurum} • 📍 {t.sehir}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="bg-amber-500 text-white font-extrabold text-xs px-3 py-1.5 rounded-lg shrink-0">
|
|
||||||
{t.alimTuru}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{agenda.undated.length>0&&<section aria-label="Tarihi doğrulanacak ihaleler" className="space-y-3 border border-amber-300 rounded-xl p-3">
|
||||||
|
<h2 className="text-sm font-bold">Tarihi doğrulanacak ihaleler ({agenda.undated.length})</h2>
|
||||||
|
<p className="text-xs">Bu kayıtlar bir güne yerleştirilemedi. İlan detayından resmi tarih ve saati kontrol edin.</p>
|
||||||
|
{agenda.undated.map(card)}
|
||||||
|
</section>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,3 +65,24 @@ export function calendarMonthDays(month) {
|
|||||||
label:new Date(`${month}-${day}T12:00:00Z`).toLocaleDateString('tr-TR',{weekday:'short',timeZone:'Europe/Istanbul'})};
|
label:new Date(`${month}-${day}T12:00:00Z`).toLocaleDateString('tr-TR',{weekday:'short',timeZone:'Europe/Istanbul'})};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Group once and order each date by known deadline; undated records remain visible. */
|
||||||
|
export function calendarAgenda(tenders, now=new Date()) {
|
||||||
|
const days=new Map(),undated=[];
|
||||||
|
for(const tender of tenders){
|
||||||
|
const entry={tender,info:deadlineInfo(tender,now)};
|
||||||
|
if(!entry.info.dateKey){undated.push(entry);continue;}
|
||||||
|
if(!days.has(entry.info.dateKey))days.set(entry.info.dateKey,[]);
|
||||||
|
days.get(entry.info.dateKey).push(entry);
|
||||||
|
}
|
||||||
|
const compare=(a,b)=>{
|
||||||
|
const aTime=a.info.deadline?.getTime(),bTime=b.info.deadline?.getTime();
|
||||||
|
if(aTime!==undefined&&bTime===undefined)return -1;
|
||||||
|
if(aTime===undefined&&bTime!==undefined)return 1;
|
||||||
|
if(aTime!==undefined&&aTime!==bTime)return aTime-bTime;
|
||||||
|
return String(a.tender.ikn || a.tender.id || '').localeCompare(String(b.tender.ikn || b.tender.id || ''),'tr');
|
||||||
|
};
|
||||||
|
for(const entries of days.values())entries.sort(compare);
|
||||||
|
undated.sort(compare);
|
||||||
|
return {days,undated};
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,3 +60,22 @@ test('deadline ICS is a transparent instant, preserves UID across date changes a
|
|||||||
assert.equal(hostile.split('\r\n').filter(line=>line==='BEGIN:VEVENT').length,1);
|
assert.equal(hostile.split('\r\n').filter(line=>line==='BEGIN:VEVENT').length,1);
|
||||||
assert.equal(hostile.split('\r\n').filter(line=>line==='END:VEVENT').length,1);
|
assert.equal(hostile.split('\r\n').filter(line=>line==='END:VEVENT').length,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calendar agenda sorts known times before unknown and preserves undated records',async()=>{
|
||||||
|
const {calendarAgenda}=await import('../src/services/tenderCalendar.js');
|
||||||
|
const now=new Date('2026-09-06T08:00:00Z');
|
||||||
|
const records=[
|
||||||
|
{id:'late',tarih:'10.09.2026',saat:'16:00'},
|
||||||
|
{id:'unknown',tarih:'10.09.2026'},
|
||||||
|
{id:'early',tarih:'10.09.2026',saat:'09:00'},
|
||||||
|
{id:'bad',tarih:'31.02.2026'},
|
||||||
|
{id:'missing'},
|
||||||
|
{id:'old',tarih:'01.09.2026',saat:'09:00',durum:'İptal'},
|
||||||
|
];
|
||||||
|
const agenda=calendarAgenda(records,now);
|
||||||
|
assert.deepEqual(agenda.days.get('2026-09-10').map(e=>e.tender.id),['early','late','unknown']);
|
||||||
|
assert.deepEqual(agenda.undated.map(e=>e.tender.id),['bad','missing']);
|
||||||
|
assert.equal(agenda.days.get('2026-09-01')[0].info.expired,true);
|
||||||
|
assert.equal([...agenda.days.values()].flat().length+agenda.undated.length,records.length);
|
||||||
|
assert.equal(records[0].id,'late');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user