Files
ftb-project-management/apps/web/components/WorkDateTimePicker.tsx
2026-06-29 14:18:58 +08:00

247 lines
9.9 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, CalendarDays, ChevronLeft, ChevronRight, Clock3, X } from 'lucide-react';
import { getChinaWorkdayInfo, getDateKey } from '@/lib/china-workday-calendar';
interface WorkDateTimePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
popoverAlign?: 'left' | 'right';
defaultHour?: number;
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
const HOURS = Array.from({ length: 24 }, (_, index) => index);
const MINUTES = [0, 15, 30, 45];
export function WorkDateTimePicker({
value,
onChange,
placeholder = '选择日期时间',
className = '',
disabled = false,
popoverAlign = 'left',
defaultHour = 9,
}: WorkDateTimePickerProps) {
const ref = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const parts = parseLocalDateTime(value, defaultHour);
const selectedDate = parts.dateKey;
const selectedInfo = selectedDate ? getChinaWorkdayInfo(selectedDate) : null;
const [viewDate, setViewDate] = useState(() => getInitialViewDate(value));
useEffect(() => {
if (!open) return;
const handler = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
const keyHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [open]);
useEffect(() => {
if (value) setViewDate(getInitialViewDate(value));
}, [value]);
const days = useMemo(() => buildCalendarDays(viewDate), [viewDate]);
const displayText = value ? value.replace('T', ' ') : '';
const hasRestDayWarning = Boolean(selectedInfo && !selectedInfo.isWorkday);
const updateDate = (dateKey: string) => {
onChange(makeLocalDateTime(dateKey, parts.hour, parts.minute));
};
const updateTime = (nextHour: number, nextMinute: number) => {
const dateKey = selectedDate || getDateKey(new Date());
onChange(makeLocalDateTime(dateKey, nextHour, nextMinute));
};
const clearValue = (event: React.MouseEvent) => {
event.stopPropagation();
onChange('');
};
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((current) => !current)}
className={`flex h-9 w-full items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-left text-[13px] transition-colors hover:border-[var(--accent)] focus:border-[var(--accent)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-[var(--ink-muted)]" />
<span className={`min-w-0 flex-1 truncate ${displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}`}>
{displayText || placeholder}
</span>
{hasRestDayWarning && <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" />}
{value && (
<span
role="button"
aria-label="清空时间"
onClick={clearValue}
className="ml-1 rounded p-0.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
>
<X className="h-3 w-3" />
</span>
)}
</button>
{open && (
<div
className={`absolute top-full z-[80] mt-1 w-[320px] rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 shadow-[var(--shadow-md)] ${
popoverAlign === 'right' ? 'right-0' : 'left-0'
}`}
>
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() => setViewDate(addMonths(viewDate, -1))}
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
aria-label="上个月"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-[13px] font-semibold text-[var(--ink)]">
{viewDate.getFullYear()}{viewDate.getMonth() + 1}
</span>
<button
type="button"
onClick={() => setViewDate(addMonths(viewDate, 1))}
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
aria-label="下个月"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1 text-center text-[10px] font-medium text-[var(--ink-muted)]">
{WEEKDAYS.map((day) => <span key={day}>{day}</span>)}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const info = getChinaWorkdayInfo(day.dateKey);
const isSelected = day.dateKey === selectedDate;
const isToday = day.dateKey === getDateKey(new Date());
const tone = getDayTone(info.type, day.inMonth, isSelected);
return (
<button
type="button"
key={day.dateKey}
onClick={() => updateDate(day.dateKey)}
title={info.label}
className={`relative flex h-9 flex-col items-center justify-center rounded-lg text-[12px] transition-colors ${tone} ${isToday && !isSelected ? 'ring-1 ring-[var(--accent-ring)]' : ''}`}
>
<span className="leading-none">{day.day}</span>
{(info.type === 'holiday' || info.type === 'makeup_workday') && (
<span className="mt-0.5 h-1 w-1 rounded-full bg-current opacity-70" />
)}
</button>
);
})}
</div>
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
<Clock3 className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<select
value={parts.hour}
onChange={(event) => updateTime(Number(event.target.value), parts.minute)}
className="h-8 flex-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none"
>
{HOURS.map((hour) => (
<option key={hour} value={hour}>{String(hour).padStart(2, '0')} </option>
))}
</select>
<select
value={parts.minute}
onChange={(event) => updateTime(parts.hour, Number(event.target.value))}
className="h-8 flex-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none"
>
{MINUTES.map((minute) => (
<option key={minute} value={minute}>{String(minute).padStart(2, '0')} </option>
))}
</select>
</div>
{selectedInfo && (
<div className={`mt-2 rounded-lg px-2.5 py-2 text-[11px] ${selectedInfo.isWorkday ? 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]' : 'bg-amber-50 text-amber-700'}`}>
{selectedInfo.isWorkday
? selectedInfo.type === 'makeup_workday' ? `${selectedInfo.label},按工作日处理` : '所选日期为工作日'
: `所选日期为${selectedInfo.label},仍可保存`}
</div>
)}
</div>
)}
</div>
);
}
function parseLocalDateTime(value: string, defaultHour: number): { dateKey: string; hour: number; minute: number } {
const matched = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/);
if (!matched) return { dateKey: '', hour: defaultHour, minute: 0 };
return {
dateKey: matched[1],
hour: Number(matched[2]),
minute: normalizeMinute(Number(matched[3])),
};
}
function normalizeMinute(minute: number): number {
if (MINUTES.includes(minute)) return minute;
return MINUTES.reduce((best, current) => Math.abs(current - minute) < Math.abs(best - minute) ? current : best, 0);
}
function makeLocalDateTime(dateKey: string, hour: number, minute: number): string {
return `${dateKey}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function getInitialViewDate(value: string): Date {
const dateKey = getDateKey(value || new Date());
const [year, month] = dateKey.split('-').map(Number);
return new Date(year, month - 1, 1);
}
function addMonths(date: Date, count: number): Date {
return new Date(date.getFullYear(), date.getMonth() + count, 1);
}
function buildCalendarDays(viewDate: Date): Array<{ dateKey: string; day: number; inMonth: boolean }> {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const first = new Date(year, month, 1);
const mondayOffset = (first.getDay() + 6) % 7;
const cursor = new Date(year, month, 1 - mondayOffset);
const days = [];
for (let index = 0; index < 42; index += 1) {
days.push({
dateKey: getDateKey(cursor),
day: cursor.getDate(),
inMonth: cursor.getMonth() === month,
});
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
function getDayTone(type: string, inMonth: boolean, selected: boolean): string {
if (selected) return 'bg-[var(--accent)] text-white shadow-sm';
if (!inMonth) return 'text-[var(--ink-muted)] opacity-35 hover:bg-[var(--bg-subtle)]';
if (type === 'holiday') return 'bg-red-50 text-red-600 hover:bg-red-100';
if (type === 'makeup_workday') return 'bg-blue-50 text-blue-600 hover:bg-blue-100';
if (type === 'weekend') return 'text-amber-600 hover:bg-amber-50';
return 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]';
}