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.
|
||||
- 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ı.
|
||||
|
||||
### 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 { calendarMonthDays, istanbulDateKey, tenderDateKey, deadlineInfo } from '../../services/tenderCalendar';
|
||||
import React, { useState } from 'react';
|
||||
import { calendarMonthDays, istanbulDateKey, calendarAgenda } from '../../services/tenderCalendar';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
|
||||
export const TenderCalendar = () => {
|
||||
const now = useDeadlineClock();
|
||||
@@ -10,8 +10,22 @@ export const TenderCalendar = () => {
|
||||
const today = istanbulDateKey(now);
|
||||
const [month,setMonth] = useState(today.slice(0,7));
|
||||
const [selectedDay, setSelectedDay] = useState(today);
|
||||
const daysList = calendarMonthDays(month).map(day => ({...day,count:tenders.filter(t=>tenderDateKey(t)===day.key).length}));
|
||||
const dayTenders = tenders.filter(t => tenderDateKey(t) === selectedDay);
|
||||
const agenda=useMemo(()=>calendarAgenda(tenders,now),[tenders,now]);
|
||||
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 (
|
||||
<div className="space-y-4 my-4 animate-fade-in">
|
||||
@@ -38,6 +52,8 @@ export const TenderCalendar = () => {
|
||||
{daysList.map(d => (
|
||||
<button
|
||||
key={d.key}
|
||||
aria-pressed={selectedDay===d.key}
|
||||
aria-label={`${d.date} ${d.label}, ${d.count} ihale`}
|
||||
onClick={() => setSelectedDay(d.key)}
|
||||
className={`min-w-0 p-3 rounded-xl border text-center transition-all cursor-pointer ${
|
||||
selectedDay === d.key
|
||||
@@ -60,31 +76,15 @@ export const TenderCalendar = () => {
|
||||
<div className="space-y-3">
|
||||
<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.map(t => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
{dayTenders.map(card)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,3 +65,24 @@ export function calendarMonthDays(month) {
|
||||
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==='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