fix: unify tender countdowns and add monthly calendar navigation

This commit is contained in:
2026-09-06 08:47:44 +03:00
parent a291b186df
commit b94ab97241
8 changed files with 123 additions and 49 deletions
+8 -7
View File
@@ -1,8 +1,11 @@
import { useDeadlineClock } from '../hooks/useDeadlineClock';
import { deadlineInfo } from '../services/tenderCalendar';
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { Kanban, User, Calendar, MapPin, Star, Plus, ArrowRight, Eye } from 'lucide-react';
export const KanbanBoard = () => {
const now = useDeadlineClock();
const { tenders, updateKanbanStatus, setActiveTenderDetail, teamMembers } = useApp();
const [selectedPersonel, setSelectedPersonel] = useState('');
const [draggedTenderId, setDraggedTenderId] = useState(null);
@@ -128,13 +131,11 @@ export const KanbanBoard = () => {
#{t.ikn}
</span>
{t.gorev && (() => {
// Teslim = ihale saati; kalan gün geri sayım (gecikme eskalasyonu kendiliğinden)
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(t.gorev.teslim || '');
if (!m) return null;
const kalan = Math.ceil((new Date(+m[3], +m[2] - 1, +m[1], +(m[4] || 23), +(m[5] || 59)) - Date.now()) / 86400000);
const renk = kalan < 0 ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800';
return <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`Teslim: ${t.gorev.teslim}`}>
{kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`}
const info = deadlineInfo(t, now);
const kalan = info.days;
const renk = info.expired || kalan === null ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800';
return <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`İhale: ${t.tarih} ${t.saat || 'saat bilinmiyor'} (Türkiye saati)`}>
{info.label}
</span>;
})()}
<button
+6 -7
View File
@@ -1,3 +1,4 @@
import { deadlineInfo } from '../services/tenderCalendar';
import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext';
import {
@@ -16,11 +17,9 @@ import { okasAra } from '../services/kalem';
const SAYFA = 20;
const gecmisMi = (tarihSaat) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(String(tarihSaat || ''));
if (!m) return false;
const [, g, a, y, ss = '23', dd = '59'] = m;
return new Date(+y, +a - 1, +g, +ss, +dd) < new Date();
const gecmisMi = tarihSaat => {
const [tarih, saat] = String(tarihSaat || '').trim().split(/\s+/);
return deadlineInfo({tarih,saat}).expired === true;
};
export const UrunAramaModal = () => {
@@ -217,7 +216,7 @@ export const UrunAramaModal = () => {
)}
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
Sadece teklif verilebilir ihaleler
Süresi geçtiği bilinenleri gizle
</label>
</div>
</form>
@@ -293,7 +292,7 @@ export const UrunAramaModal = () => {
{liste.length === 0 && (
<p className="text-xs text-slate-500 text-center py-6">
{sonuc.totalCount > 0
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Sadece teklif verilebilir" kutusunu kapatarak görebilirsiniz.'
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Süresi geçtiği bilinenleri gizle" kutusunu kapatarak görebilirsiniz.'
: 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'}
</p>
)}
+11 -11
View File
@@ -1,25 +1,25 @@
import { useDeadlineClock } from '../hooks/useDeadlineClock';
import React, { useMemo } from 'react';
import { CalendarClock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { parseDateTR } from '../services/analytics';
import { deadlineInfo } from '../services/tenderCalendar';
/**
* Sağ panel: takipteki / panodaki ihalelerden 14 gün içinde yapılacaklar.
* Sunucuya gitmez — liste zaten bellekte. ≤3 gün kırmızı, ≤7 turuncu.
*/
export const YaklasanSonTarihler = ({ gun = 14 }) => {
const now = useDeadlineClock();
const { tenders, setActiveTenderDetail } = useApp();
const liste = useMemo(() => {
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
const sinir = new Date(bugun.getTime() + gun * 86400000);
return tenders
.filter((t) => t.takipte || t.kanbanStatus || t.gorev)
.map((t) => ({ t, d: parseDateTR(t.tarih) }))
.filter(({ d }) => d && d >= bugun && d <= sinir)
.sort((a, b) => a.d - b.d)
.map(({ t, d }) => ({ t, kalan: Math.round((d - bugun) / 86400000) }))
.filter(t => t.takipte || t.gorev)
.map(t => ({t,info:deadlineInfo(t,now)}))
.filter(({info}) => info.days !== null && info.days >= 0 && info.days <= gun && info.expired !== true)
.sort((a,b) => a.info.days - b.info.days || (a.info.deadline?.getTime() || Infinity) - (b.info.deadline?.getTime() || Infinity))
.map(({t,info}) => ({t,kalan:info.days,label:info.label}))
.slice(0, 8);
}, [tenders, gun]);
}, [tenders, gun, now]);
const renk = (g) => (g <= 3 ? 'bg-red-600 text-white' : g <= 7 ? 'bg-amber-500 text-white' : 'bg-slate-200 text-slate-800');
@@ -33,11 +33,11 @@ export const YaklasanSonTarihler = ({ gun = 14 }) => {
<div className="p-2 space-y-1">
{liste.length === 0 ? (
<p className="px-2 py-3 text-xs text-slate-500">{gun} gün içinde yapılacak takipli ihale yok. Yıldızla takibe alın; burada geri sayım başlar.</p>
) : liste.map(({ t, kalan }) => (
) : liste.map(({ t, kalan, label }) => (
<button key={t.id} type="button" onClick={() => setActiveTenderDetail?.(t)}
className="w-full flex items-center justify-between gap-2 rounded-lg p-2 text-xs hover:bg-slate-50 text-left">
<span className="min-w-0 truncate font-medium text-slate-800" title={t.baslik}>{t.baslik}</span>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${renk(kalan)}`}>{kalan === 0 ? 'BUGÜN' : `${kalan} gün`}</span>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${renk(kalan)}`}>{label}</span>
</button>
))}
</div>
+26 -22
View File
@@ -1,20 +1,17 @@
import { useDeadlineClock } from '../../hooks/useDeadlineClock';
import { calendarMonthDays, istanbulDateKey, tenderDateKey, deadlineInfo } from '../../services/tenderCalendar';
import React, { useState } from 'react';
import { useApp } from '../../context/AppContext';
import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react';
export const TenderCalendar = () => {
const now = useDeadlineClock();
const { tenders, setActiveTenderDetail } = useApp();
const GUNLER = ['Pazar', 'Pazartesi', 'Salı', 'Çarşamba', 'Perşembe', 'Cuma', 'Cumartesi'];
const ggaayyyy = (d) => `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
// Bugünden başlayan 7 gün; sayılar havuzdaki gerçek ihale tarihlerinden.
const daysList = Array.from({ length: 7 }, (_, i) => {
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + i);
const date = ggaayyyy(d);
return { date, label: i === 0 ? `Bugün (${GUNLER[d.getDay()]})` : i === 1 ? `Yarın (${GUNLER[d.getDay()]})` : GUNLER[d.getDay()], count: tenders.filter(t => t.tarih === date).length };
});
const [selectedDay, setSelectedDay] = useState(daysList[0].date);
const dayTenders = tenders.filter(t => t.tarih === selectedDay);
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);
return (
<div className="space-y-4 my-4 animate-fade-in">
@@ -26,30 +23,34 @@ export const TenderCalendar = () => {
<CalendarDays className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-black text-slate-900">7 Günlük İhale Takvimi</h1>
<p className="text-xs text-slate-500">Önümüzdeki 7 gün içerisinde kapanacak ihaleleri günü gününe takip edin.</p>
<h1 className="text-xl font-black text-slate-900">İhale Takvimi</h1>
<p className="text-xs text-slate-500">Ay ve gün seçerek ihale tarihlerini görüntüleyin. Saatler Türkiye saatidir.</p>
</div>
</div>
</div>
<label className="block text-sm font-semibold">Takvim ayı
<input type="month" value={month} onChange={e => {if(calendarMonthDays(e.target.value).length){setMonth(e.target.value);setSelectedDay(e.target.value+'-01');}}}
className="ml-3 border border-slate-300 rounded-lg p-2"/>
</label>
{/* Day Selector Buttons */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
{daysList.map(d => (
<button
key={d.date}
onClick={() => setSelectedDay(d.date)}
className={`flex-1 min-w-[130px] p-3 rounded-xl border text-center transition-all cursor-pointer ${
selectedDay === d.date
key={d.key}
onClick={() => setSelectedDay(d.key)}
className={`min-w-0 p-3 rounded-xl border text-center transition-all cursor-pointer ${
selectedDay === d.key
? 'bg-purple-600 text-white border-purple-700 shadow-sm font-bold'
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50 font-semibold'
}`}
>
<span className="text-[11px] block text-purple-200 opacity-90">{d.label}</span>
<span className="text-[11px] block opacity-90">{d.label}</span>
<span className="text-sm font-black block mt-0.5">{d.date}</span>
<span className={`text-[10px] mt-1 inline-block px-2 py-0.5 rounded-full ${
selectedDay === d.date ? 'bg-white/20 text-white font-extrabold' : 'bg-purple-100 text-purple-800 font-bold'
selectedDay === d.key ? 'bg-white/20 text-white font-extrabold' : 'bg-purple-100 text-purple-800 font-bold'
}`}>
{d.count} İhale Kapanıyor
{d.count} İhale
</span>
</button>
))}
@@ -57,16 +58,19 @@ export const TenderCalendar = () => {
{/* Tenders List for Selected Day */}
<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">
⏰ {t.saat}
⏰ {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>
+10
View File
@@ -0,0 +1,10 @@
import {useEffect, useState} from 'react';
// Keep expiry labels current even when the user leaves a screen open.
export function useDeadlineClock() {
const [now,setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()),30000);
return () => clearInterval(timer);
},[]);
return now;
}
+29
View File
@@ -36,3 +36,32 @@ export function createTenderICS(t, now = new Date()) {
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`,
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
}
export const istanbulDateKey = (now = new Date()) => new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10);
export function tenderDateKey(t) {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:$|\s)/.exec(String(t?.tarih || ''));
if (!m) return null;
const key = `${m[3]}-${m[2]}-${m[1]}`;
const date = new Date(key + 'T00:00:00Z');
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0,10) === key ? key : null;
}
export function deadlineInfo(t, now = new Date()) {
const dateKey = tenderDateKey(t);
if (!dateKey) return {dateKey:null,days:null,expired:null,label:'Tarih doğrulanmalı',deadline:null};
const days = Math.round((Date.parse(dateKey+'T00:00:00Z') - Date.parse(istanbulDateKey(now)+'T00:00:00Z')) / 86400000);
let deadline = null;
try { deadline = tenderDeadline(t); } catch { /* an unknown time is not midnight */ }
const expired = deadline ? deadline.getTime() <= now.getTime() : days < 0 ? true : null;
const label = expired ? 'Süresi geçti' : `${days === 0 ? 'BUGÜN' : days + ' gün'}${deadline ? '' : ' · saat doğrulanmalı'}`;
return {dateKey,days,expired,label,deadline};
}
export function calendarMonthDays(month) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) return [];
const [year,m] = month.split('-').map(Number);
const count = new Date(Date.UTC(year,m,0)).getUTCDate();
return Array.from({length:count},(_,i)=>{
const day=String(i+1).padStart(2,'0');
return {key:`${month}-${day}`,date:`${day}.${String(m).padStart(2,'0')}.${year}`,
label:new Date(`${month}-${day}T12:00:00Z`).toLocaleDateString('tr-TR',{weekday:'short',timeZone:'Europe/Istanbul'})};
});
}