332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useCallback } from 'react';
|
||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar';
|
||
import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay';
|
||
import dayjs, { Dayjs } from 'dayjs';
|
||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||
import 'dayjs/locale/ru';
|
||
import Popover from '@mui/material/Popover';
|
||
|
||
dayjs.extend(isoWeek);
|
||
dayjs.locale('ru');
|
||
|
||
export interface DateRangeValue {
|
||
start_date: string; // YYYY-MM-DD
|
||
end_date: string;
|
||
}
|
||
|
||
export interface DateRangePickerProps {
|
||
value: DateRangeValue;
|
||
onChange: (range: DateRangeValue) => void;
|
||
disabled?: boolean;
|
||
placeholder?: string;
|
||
}
|
||
|
||
const PRESETS = [
|
||
{
|
||
label: 'Эта неделя',
|
||
fn: () => ({
|
||
start_date: dayjs().startOf('isoWeek').format('YYYY-MM-DD'),
|
||
end_date: dayjs().endOf('isoWeek').format('YYYY-MM-DD'),
|
||
}),
|
||
},
|
||
{
|
||
label: 'Прошлая неделя',
|
||
fn: () => {
|
||
const w = dayjs().subtract(1, 'week');
|
||
return { start_date: w.startOf('isoWeek').format('YYYY-MM-DD'), end_date: w.endOf('isoWeek').format('YYYY-MM-DD') };
|
||
},
|
||
},
|
||
{
|
||
label: 'Последние 7 дней',
|
||
fn: () => ({
|
||
start_date: dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||
end_date: dayjs().format('YYYY-MM-DD'),
|
||
}),
|
||
},
|
||
{
|
||
label: 'Текущий месяц',
|
||
fn: () => ({
|
||
start_date: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||
end_date: dayjs().endOf('month').format('YYYY-MM-DD'),
|
||
}),
|
||
},
|
||
{
|
||
label: 'След. месяц',
|
||
fn: () => {
|
||
const m = dayjs().add(1, 'month');
|
||
return { start_date: m.startOf('month').format('YYYY-MM-DD'), end_date: m.endOf('month').format('YYYY-MM-DD') };
|
||
},
|
||
},
|
||
];
|
||
|
||
export function DateRangePicker({
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
placeholder = 'Выберите период',
|
||
}: DateRangePickerProps) {
|
||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||
const [localStart, setLocalStart] = useState<Dayjs | null>(() => (value.start_date ? dayjs(value.start_date) : null));
|
||
const [localEnd, setLocalEnd] = useState<Dayjs | null>(() => (value.end_date ? dayjs(value.end_date) : null));
|
||
const [rangeMode, setRangeMode] = useState<'start' | 'end'>('start');
|
||
|
||
const syncFromValue = useCallback(() => {
|
||
setLocalStart(value.start_date ? dayjs(value.start_date) : null);
|
||
setLocalEnd(value.end_date ? dayjs(value.end_date) : null);
|
||
}, [value.start_date, value.end_date]);
|
||
|
||
const openPopover = (e: React.MouseEvent<HTMLElement>) => {
|
||
if (disabled) return;
|
||
syncFromValue();
|
||
setAnchorEl(e.currentTarget);
|
||
};
|
||
|
||
const closePopover = () => setAnchorEl(null);
|
||
|
||
const handleApply = () => {
|
||
if (localStart && localEnd) {
|
||
onChange({
|
||
start_date: localStart.format('YYYY-MM-DD'),
|
||
end_date: localEnd.format('YYYY-MM-DD'),
|
||
});
|
||
}
|
||
closePopover();
|
||
};
|
||
|
||
const displayText =
|
||
value.start_date && value.end_date
|
||
? `${dayjs(value.start_date).format('DD.MM.YYYY')} — ${dayjs(value.end_date).format('DD.MM.YYYY')}`
|
||
: placeholder;
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={openPopover}
|
||
disabled={disabled}
|
||
style={{
|
||
padding: '8px 12px',
|
||
borderRadius: 12,
|
||
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
||
background: 'var(--md-sys-color-surface-container-low)',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
fontSize: 14,
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
outline: 'none',
|
||
minWidth: 200,
|
||
textAlign: 'left',
|
||
opacity: disabled ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{displayText}
|
||
</button>
|
||
<Popover
|
||
open={Boolean(anchorEl)}
|
||
anchorEl={anchorEl}
|
||
onClose={closePopover}
|
||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||
slotProps={{
|
||
paper: {
|
||
sx: {
|
||
mt: 1,
|
||
borderRadius: 12,
|
||
minWidth: 300,
|
||
maxWidth: 360,
|
||
overflow: 'hidden',
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru">
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 12,
|
||
padding: 16,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: 'var(--md-sys-color-primary)',
|
||
letterSpacing: '0.05em',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
Выберите период
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 15,
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
marginBottom: 2,
|
||
}}
|
||
>
|
||
{localStart && localEnd
|
||
? `${localStart.format('D MMM')} — ${localEnd.format('D MMM')}`
|
||
: '—'}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
{rangeMode === 'start' ? 'Клик: начало периода' : 'Клик: конец периода'}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{PRESETS.map(({ label, fn }) => (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
onClick={() => {
|
||
const r = fn();
|
||
setLocalStart(dayjs(r.start_date));
|
||
setLocalEnd(dayjs(r.end_date));
|
||
setRangeMode('end');
|
||
}}
|
||
style={{
|
||
padding: '6px 10px',
|
||
borderRadius: 8,
|
||
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
||
background: 'var(--md-sys-color-surface-container-low)',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
outline: 'none',
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<DateCalendar
|
||
value={rangeMode === 'start' ? (localStart ?? localEnd ?? dayjs()) : (localEnd ?? localStart ?? dayjs())}
|
||
onChange={(val) => {
|
||
const d = val ? dayjs(val as Date) : null;
|
||
if (!d) return;
|
||
if (rangeMode === 'start') {
|
||
setLocalStart(d);
|
||
setLocalEnd(null);
|
||
setRangeMode('end');
|
||
} else {
|
||
if (localStart && d.isBefore(localStart, 'day')) {
|
||
setLocalStart(d);
|
||
setLocalEnd(localStart);
|
||
} else {
|
||
setLocalEnd(d);
|
||
}
|
||
}
|
||
}}
|
||
slots={{
|
||
day: (props: PickersDayProps) => {
|
||
const { day, selected, sx, ...rest } = props;
|
||
const d = dayjs(day as Date);
|
||
const start = localStart ? dayjs(localStart).startOf('day') : null;
|
||
const end = localEnd ? dayjs(localEnd).startOf('day') : null;
|
||
const isStart = start && d.isSame(start, 'day');
|
||
const isEnd = end && d.isSame(end, 'day');
|
||
const inBetween = start && end && !isStart && !isEnd && d.isAfter(start) && d.isBefore(end);
|
||
const inRange = isStart || isEnd || inBetween;
|
||
return (
|
||
<PickersDay
|
||
{...rest}
|
||
day={day}
|
||
selected={selected || !!inRange}
|
||
sx={[
|
||
...(Array.isArray(sx) ? sx : sx != null ? [sx] : []),
|
||
...(inBetween
|
||
? [
|
||
{
|
||
bgcolor: 'rgba(103, 80, 164, 0.16)',
|
||
borderRadius: 0,
|
||
'&:hover': { bgcolor: 'rgba(103, 80, 164, 0.24)' },
|
||
},
|
||
]
|
||
: []),
|
||
...(isStart
|
||
? [
|
||
{
|
||
borderTopLeftRadius: '50%',
|
||
borderBottomLeftRadius: '50%',
|
||
bgcolor: 'var(--md-sys-color-primary)',
|
||
color: 'var(--md-sys-color-on-primary)',
|
||
'&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(1.1)' },
|
||
},
|
||
]
|
||
: []),
|
||
...(isEnd
|
||
? [
|
||
{
|
||
borderTopRightRadius: '50%',
|
||
borderBottomRightRadius: '50%',
|
||
bgcolor: 'var(--md-sys-color-primary)',
|
||
color: 'var(--md-sys-color-on-primary)',
|
||
'&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(1.1)' },
|
||
},
|
||
]
|
||
: []),
|
||
]}
|
||
/>
|
||
);
|
||
},
|
||
}}
|
||
sx={{ width: '100%', '& .MuiPickersCalendarHeader-root': { marginBottom: 0 } }}
|
||
/>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: 8,
|
||
paddingTop: 8,
|
||
borderTop: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.08))',
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={closePopover}
|
||
style={{
|
||
padding: '8px 14px',
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
background: 'transparent',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: 14,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleApply}
|
||
disabled={!localStart || !localEnd}
|
||
style={{
|
||
padding: '8px 14px',
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
background: localStart && localEnd ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface-variant)',
|
||
color: localStart && localEnd ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: localStart && localEnd ? 'pointer' : 'not-allowed',
|
||
}}
|
||
>
|
||
Применить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</LocalizationProvider>
|
||
</Popover>
|
||
</>
|
||
);
|
||
}
|