247 lines
9.9 KiB
TypeScript
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)]';
|
|
}
|