uchill/front_material/components/common/DateRangePicker.tsx

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
</>
);
}