119 lines
9.9 KiB
TypeScript
119 lines
9.9 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
|
|
|
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
|
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
|
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
|
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
|
|
|
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
|
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
const [draft, setDraft] = useState(value);
|
|
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
|
const [month, setMonth] = useState(() => parseDate(value.start));
|
|
const [hovered, setHovered] = useState('');
|
|
const today = ymd(new Date());
|
|
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
|
const valid = count > 0 && count <= 370;
|
|
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
const previewEnd = draft.end || hovered || draft.start;
|
|
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
|
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
|
const quickPicks = [
|
|
{ label: '今天', range: { start: today, end: today } },
|
|
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
|
|
{ label: '业务周', range: getWeeklyFlowRange() },
|
|
{ label: '近 7 天', range: getRecentFlowRange() },
|
|
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
|
|
];
|
|
const open = () => {
|
|
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
|
dialog.current?.showModal();
|
|
};
|
|
const choose = (date: string) => {
|
|
if (selecting === 'start') {
|
|
setDraft({ start: date, end: '' }); setSelecting('end');
|
|
} else {
|
|
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
|
setSelecting('start');
|
|
}
|
|
setHovered('');
|
|
};
|
|
const calendar = (offset: number) => {
|
|
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
|
const year = current.getFullYear(), m = current.getMonth();
|
|
const padding = (current.getDay() + 6) % 7;
|
|
const length = new Date(year, m + 1, 0).getDate();
|
|
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
|
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
|
<div className="grid grid-cols-7 text-center">
|
|
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
|
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
|
{Array.from({ length }, (_, i) => {
|
|
const date = ymd(new Date(year, m, i + 1));
|
|
const endpoint = date === draft.start || date === draft.end;
|
|
const inRange = date >= rangeStart && date <= rangeEnd;
|
|
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
|
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
|
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
|
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
|
</button>;
|
|
})}
|
|
</div>
|
|
</div>;
|
|
};
|
|
return <>
|
|
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
|
|
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
|
|
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
|
</span>
|
|
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
|
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
|
</button>
|
|
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
|
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
|
<div className="p-5 sm:p-6">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
|
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
|
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
|
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
|
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
|
</button>)}
|
|
</div>
|
|
<div className="mt-5 flex items-center justify-between gap-2">
|
|
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
|
<div className="flex gap-2">
|
|
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
|
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
|
</select>
|
|
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
|
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
|
</select>
|
|
</div>
|
|
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
|
</div>
|
|
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
|
</div>
|
|
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
|
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
|
|
<div className="flex shrink-0 gap-2">
|
|
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
|
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
</>;
|
|
}
|