init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import type { ICalendarFilters } from 'src/types/calendar';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import type { FiltersResultProps } from 'src/components/filters-result';
import { useCallback } from 'react';
import { varAlpha } from 'minimal-shared/utils';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import { fDateRangeShortLabel } from 'src/utils/format-time';
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
// ----------------------------------------------------------------------
type Props = FiltersResultProps & {
filters: UseSetStateReturn<ICalendarFilters>;
};
export function CalendarFiltersResult({ filters, totalResults, sx }: Props) {
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
const handleRemoveColor = useCallback(
(inputValue: string) => {
const newValue = currentFilters.colors.filter((item) => item !== inputValue);
updateFilters({ colors: newValue });
},
[updateFilters, currentFilters.colors]
);
const handleRemoveDate = useCallback(() => {
updateFilters({ startDate: null, endDate: null });
}, [updateFilters]);
return (
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
<FiltersBlock label="Colors:" isShow={!!currentFilters.colors.length}>
{currentFilters.colors.map((item) => (
<Chip
{...chipProps}
key={item}
label={
<Box
sx={[
(theme) => ({
ml: -0.5,
width: 18,
height: 18,
bgcolor: item,
borderRadius: '50%',
border: `solid 1px ${varAlpha(theme.vars.palette.common.whiteChannel, 0.24)}`,
}),
]}
/>
}
onDelete={() => handleRemoveColor(item)}
/>
))}
</FiltersBlock>
<FiltersBlock
label="Date:"
isShow={Boolean(currentFilters.startDate && currentFilters.endDate)}
>
<Chip
{...chipProps}
label={fDateRangeShortLabel(currentFilters.startDate, currentFilters.endDate)}
onDelete={handleRemoveDate}
/>
</FiltersBlock>
</FiltersResult>
);
}

View File

@@ -0,0 +1,226 @@
import type { IDatePickerControl } from 'src/types/common';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import type { ICalendarEvent, ICalendarFilters } from 'src/types/calendar';
import { useCallback } from 'react';
import { orderBy } from 'es-toolkit';
import Box from '@mui/material/Box';
import Badge from '@mui/material/Badge';
import Drawer from '@mui/material/Drawer';
import Divider from '@mui/material/Divider';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { fDate, fDateTime } from 'src/utils/format-time';
import { Iconify } from 'src/components/iconify';
import { Scrollbar } from 'src/components/scrollbar';
import { ColorPicker } from 'src/components/color-utils';
// ----------------------------------------------------------------------
type Props = {
open: boolean;
canReset: boolean;
dateError: boolean;
onClose: () => void;
colorOptions: string[];
events: ICalendarEvent[];
onClickEvent: (eventId: string) => void;
filters: UseSetStateReturn<ICalendarFilters>;
};
export function CalendarFilters({
open,
events,
onClose,
filters,
canReset,
dateError,
colorOptions,
onClickEvent,
}: Props) {
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
const handleFilterColors = useCallback(
(newValue: string | string[]) => {
updateFilters({ colors: newValue as string[] });
},
[updateFilters]
);
const handleFilterStartDate = useCallback(
(newValue: IDatePickerControl) => {
updateFilters({ startDate: newValue });
},
[updateFilters]
);
const handleFilterEndDate = useCallback(
(newValue: IDatePickerControl) => {
updateFilters({ endDate: newValue });
},
[updateFilters]
);
const renderHead = () => (
<>
<Box
sx={{
py: 2,
pr: 1,
pl: 2.5,
display: 'flex',
alignItems: 'center',
}}
>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Filters
</Typography>
<Tooltip title="Reset">
<IconButton onClick={() => resetFilters()}>
<Badge color="error" variant="dot" invisible={!canReset}>
<Iconify icon="solar:restart-bold" />
</Badge>
</IconButton>
</Tooltip>
<IconButton onClick={onClose}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Box>
<Divider sx={{ borderStyle: 'dashed' }} />
</>
);
const renderColors = () => (
<Box
sx={{
my: 3,
px: 2.5,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Colors
</Typography>
<ColorPicker
options={colorOptions}
value={currentFilters.colors}
onChange={handleFilterColors}
/>
</Box>
);
const renderDateRange = () => (
<Box
sx={{
mb: 3,
px: 2.5,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
Range
</Typography>
<DatePicker
label="Start date"
value={currentFilters.startDate}
onChange={handleFilterStartDate}
sx={{ mb: 2.5 }}
/>
<DatePicker
label="End date"
value={currentFilters.endDate}
onChange={handleFilterEndDate}
slotProps={{
textField: {
error: dateError,
helperText: dateError ? 'End date must be later than start date' : null,
},
}}
/>
</Box>
);
const renderEvents = () => (
<>
<Typography variant="subtitle2" sx={{ px: 2.5, mb: 1 }}>
Events ({events.length})
</Typography>
<Box component="ul">
{orderBy(events, ['end'], ['desc']).map((event) => (
<li key={event.id}>
<ListItemButton
onClick={() => onClickEvent(`${event.id}`)}
sx={[
(theme) => ({ py: 1.5, borderBottom: `dashed 1px ${theme.vars.palette.divider}` }),
]}
>
<Box
sx={{
top: 16,
left: 0,
width: 0,
height: 0,
position: 'absolute',
borderRight: '10px solid transparent',
borderTop: `10px solid ${event.color}`,
}}
/>
<ListItemText
primary={
event.allDay
? fDate(event.start)
: `${fDateTime(event.start)} - ${fDateTime(event.end)}`
}
secondary={event.title}
slotProps={{
primary: {
sx: { typography: 'caption', color: 'text.disabled' },
},
secondary: {
sx: { mt: 0.5, color: 'text.primary', typography: 'subtitle2' },
},
}}
/>
</ListItemButton>
</li>
))}
</Box>
</>
);
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
slotProps={{
backdrop: { invisible: true },
paper: { sx: { width: 320 } },
}}
>
{renderHead()}
<Scrollbar>
{renderColors()}
{renderDateRange()}
{renderEvents()}
</Scrollbar>
</Drawer>
);
}

View File

@@ -0,0 +1,169 @@
import type { ICalendarEvent } from 'src/types/calendar';
import { z as zod } from 'zod';
import { useCallback } from 'react';
import { uuidv4 } from 'minimal-shared/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, Controller } from 'react-hook-form';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import DialogActions from '@mui/material/DialogActions';
import { fIsAfter } from 'src/utils/format-time';
import { createEvent, updateEvent, deleteEvent } from 'src/actions/calendar';
import { toast } from 'src/components/snackbar';
import { Iconify } from 'src/components/iconify';
import { Scrollbar } from 'src/components/scrollbar';
import { Form, Field } from 'src/components/hook-form';
import { ColorPicker } from 'src/components/color-utils';
// ----------------------------------------------------------------------
export type EventSchemaType = zod.infer<typeof EventSchema>;
export const EventSchema = zod.object({
title: zod
.string()
.min(1, { message: 'Title is required!' })
.max(100, { message: 'Title must be less than 100 characters' }),
description: zod
.string()
.min(1, { message: 'Description is required!' })
.min(50, { message: 'Description must be at least 50 characters' }),
// Not required
color: zod.string(),
allDay: zod.boolean(),
start: zod.union([zod.string(), zod.number()]),
end: zod.union([zod.string(), zod.number()]),
});
// ----------------------------------------------------------------------
type Props = {
colorOptions: string[];
onClose: () => void;
currentEvent?: ICalendarEvent;
};
export function CalendarForm({ currentEvent, colorOptions, onClose }: Props) {
const methods = useForm<EventSchemaType>({
mode: 'all',
resolver: zodResolver(EventSchema),
defaultValues: currentEvent,
});
const {
reset,
watch,
control,
handleSubmit,
formState: { isSubmitting },
} = methods;
const values = watch();
const dateError = fIsAfter(values.start, values.end);
const onSubmit = handleSubmit(async (data) => {
const eventData = {
id: currentEvent?.id ? currentEvent?.id : uuidv4(),
color: data?.color,
title: data?.title,
allDay: data?.allDay,
description: data?.description,
end: data?.end,
start: data?.start,
};
try {
if (!dateError) {
if (currentEvent?.id) {
await updateEvent(eventData);
toast.success('Update success!');
} else {
await createEvent(eventData);
toast.success('Create success!');
}
onClose();
reset();
}
} catch (error) {
console.error(error);
}
});
const onDelete = useCallback(async () => {
try {
await deleteEvent(`${currentEvent?.id}`);
toast.success('Delete success!');
onClose();
} catch (error) {
console.error(error);
}
}, [currentEvent?.id, onClose]);
return (
<Form methods={methods} onSubmit={onSubmit}>
<Scrollbar sx={{ p: 3, bgcolor: 'background.neutral' }}>
<Stack spacing={3}>
<Field.Text name="title" label="Title" />
<Field.Text name="description" label="Description" multiline rows={3} />
<Field.Switch name="allDay" label="All day" />
<Field.MobileDateTimePicker name="start" label="Start date" />
<Field.MobileDateTimePicker
name="end"
label="End date"
slotProps={{
textField: {
error: dateError,
helperText: dateError ? 'End date must be later than start date' : null,
},
}}
/>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorPicker
value={field.value as string}
onChange={(color) => field.onChange(color as string)}
options={colorOptions}
/>
)}
/>
</Stack>
</Scrollbar>
<DialogActions sx={{ flexShrink: 0 }}>
{!!currentEvent?.id && (
<Tooltip title="Delete event">
<IconButton color="error" onClick={onDelete}>
<Iconify icon="solar:trash-bin-trash-bold" />
</IconButton>
</Tooltip>
)}
<Box sx={{ flexGrow: 1 }} />
<Button variant="outlined" color="inherit" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="contained" loading={isSubmitting} disabled={dateError}>
Save changes
</Button>
</DialogActions>
</Form>
);
}

View File

@@ -0,0 +1,146 @@
import type { IDateValue } from 'src/types/common';
import type { ICalendarView } from 'src/types/calendar';
import { usePopover } from 'minimal-shared/hooks';
import Box from '@mui/material/Box';
import Badge from '@mui/material/Badge';
import Button from '@mui/material/Button';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import LinearProgress from '@mui/material/LinearProgress';
import { Iconify } from 'src/components/iconify';
import { CustomPopover } from 'src/components/custom-popover';
// ----------------------------------------------------------------------
const VIEW_OPTIONS = [
{ value: 'dayGridMonth', label: 'Month', icon: 'mingcute:calendar-month-line' },
{ value: 'timeGridWeek', label: 'Week', icon: 'mingcute:calendar-week-line' },
{ value: 'timeGridDay', label: 'Day', icon: 'mingcute:calendar-day-line' },
{ value: 'listWeek', label: 'Agenda', icon: 'custom:calendar-agenda-outline' },
] as const;
// ----------------------------------------------------------------------
type Props = {
loading: boolean;
canReset: boolean;
view: ICalendarView;
date: IDateValue;
onToday: () => void;
onNextDate: () => void;
onPrevDate: () => void;
onOpenFilters: () => void;
onChangeView: (newView: ICalendarView) => void;
};
export function CalendarToolbar({
date,
view,
loading,
onToday,
canReset,
onNextDate,
onPrevDate,
onChangeView,
onOpenFilters,
}: Props) {
const menuActions = usePopover();
const selectedItem = VIEW_OPTIONS.filter((item) => item.value === view)[0];
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
slotProps={{ arrow: { placement: 'top-left' } }}
>
<MenuList>
{VIEW_OPTIONS.map((viewOption) => (
<MenuItem
key={viewOption.value}
selected={viewOption.value === view}
onClick={() => {
menuActions.onClose();
onChangeView(viewOption.value);
}}
>
<Iconify icon={viewOption.icon} />
{viewOption.label}
</MenuItem>
))}
</MenuList>
</CustomPopover>
);
return (
<>
<Box
sx={{
p: 2.5,
pr: 2,
display: 'flex',
position: 'relative',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Button
size="small"
color="inherit"
onClick={menuActions.onOpen}
startIcon={<Iconify icon={selectedItem.icon} />}
endIcon={<Iconify icon="eva:arrow-ios-downward-fill" sx={{ ml: -0.5 }} />}
sx={{ display: { xs: 'none', sm: 'inline-flex' } }}
>
{selectedItem.label}
</Button>
<Box sx={{ gap: 1, display: 'flex', alignItems: 'center' }}>
<IconButton onClick={onPrevDate}>
<Iconify icon="eva:arrow-ios-back-fill" />
</IconButton>
<Typography variant="h6">{date}</Typography>
<IconButton onClick={onNextDate}>
<Iconify icon="eva:arrow-ios-forward-fill" />
</IconButton>
</Box>
<Box sx={{ gap: 1, display: 'flex', alignItems: 'center' }}>
<Button size="small" color="error" variant="contained" onClick={onToday}>
Today
</Button>
<IconButton onClick={onOpenFilters}>
<Badge color="error" variant="dot" invisible={!canReset}>
<Iconify icon="ic:round-filter-list" />
</Badge>
</IconButton>
</Box>
{loading && (
<LinearProgress
color="inherit"
sx={{
left: 0,
width: 1,
height: 2,
bottom: 0,
borderRadius: 0,
position: 'absolute',
}}
/>
)}
</Box>
{renderMenuActions()}
</>
);
}

View File

@@ -0,0 +1,174 @@
import type FullCalendar from '@fullcalendar/react';
import type { EventResizeDoneArg } from '@fullcalendar/interaction/index.js';
import type { EventDropArg, DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js';
import type { ICalendarView, ICalendarRange, ICalendarEvent } from 'src/types/calendar';
import { useRef, useState, useCallback } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
// ----------------------------------------------------------------------
export function useCalendar() {
const calendarRef = useRef<FullCalendar>(null);
const calendarEl = calendarRef.current;
const smUp = useMediaQuery((theme) => theme.breakpoints.up('sm'));
const [date, setDate] = useState(new Date());
const [openForm, setOpenForm] = useState(false);
const [selectEventId, setSelectEventId] = useState('');
const [selectedRange, setSelectedRange] = useState<ICalendarRange>(null);
const [view, setView] = useState<ICalendarView>(smUp ? 'dayGridMonth' : 'listWeek');
const onOpenForm = useCallback(() => {
setOpenForm(true);
}, []);
const onCloseForm = useCallback(() => {
setOpenForm(false);
setSelectedRange(null);
setSelectEventId('');
}, []);
const onInitialView = useCallback(() => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
const newView = smUp ? 'dayGridMonth' : 'listWeek';
calendarApi.changeView(newView);
setView(newView);
}
}, [calendarEl, smUp]);
const onChangeView = useCallback(
(newView: ICalendarView) => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.changeView(newView);
setView(newView);
}
},
[calendarEl]
);
const onDateToday = useCallback(() => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.today();
setDate(calendarApi.getDate());
}
}, [calendarEl]);
const onDatePrev = useCallback(() => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.prev();
setDate(calendarApi.getDate());
}
}, [calendarEl]);
const onDateNext = useCallback(() => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.next();
setDate(calendarApi.getDate());
}
}, [calendarEl]);
const onSelectRange = useCallback(
(arg: DateSelectArg) => {
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.unselect();
}
onOpenForm();
setSelectedRange({ start: arg.startStr, end: arg.endStr });
},
[calendarEl, onOpenForm]
);
const onClickEvent = useCallback(
(arg: EventClickArg) => {
const { event } = arg;
onOpenForm();
setSelectEventId(event.id);
},
[onOpenForm]
);
const onResizeEvent = useCallback(
(arg: EventResizeDoneArg, updateEvent: (eventData: Partial<ICalendarEvent>) => void) => {
const { event } = arg;
updateEvent({
id: event.id,
allDay: event.allDay,
start: event.startStr,
end: event.endStr,
});
},
[]
);
const onDropEvent = useCallback(
(arg: EventDropArg, updateEvent: (eventData: Partial<ICalendarEvent>) => void) => {
const { event } = arg;
updateEvent({
id: event.id,
allDay: event.allDay,
start: event.startStr,
end: event.endStr,
});
},
[]
);
const onClickEventInFilters = useCallback(
(eventId: string) => {
if (eventId) {
onOpenForm();
setSelectEventId(eventId);
}
},
[onOpenForm]
);
return {
calendarRef,
/********/
view,
date,
/********/
onDatePrev,
onDateNext,
onDateToday,
onDropEvent,
onClickEvent,
onChangeView,
onSelectRange,
onResizeEvent,
onInitialView,
/********/
openForm,
onOpenForm,
onCloseForm,
/********/
selectEventId,
selectedRange,
/********/
onClickEventInFilters,
};
}

View File

@@ -0,0 +1,40 @@
import type { ICalendarEvent, ICalendarRange } from 'src/types/calendar';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { CALENDAR_COLOR_OPTIONS } from 'src/_mock/_calendar';
// ----------------------------------------------------------------------
export function useEvent(
events: ICalendarEvent[],
selectEventId: string,
selectedRange: ICalendarRange,
openForm: boolean
) {
const currentEvent = events.find((event) => event.id === selectEventId);
const defaultValues: ICalendarEvent = useMemo(
() => ({
id: '',
title: '',
description: '',
color: CALENDAR_COLOR_OPTIONS[1],
allDay: false,
start: selectedRange ? selectedRange.start : dayjs(new Date()).format(),
end: selectedRange ? selectedRange.end : dayjs(new Date()).format(),
}),
[selectedRange]
);
if (!openForm) {
return undefined;
}
if (currentEvent || selectedRange) {
return { ...defaultValues, ...currentEvent };
}
return defaultValues;
}

View File

@@ -0,0 +1,137 @@
import { varAlpha } from 'minimal-shared/utils';
import { styled } from '@mui/material/styles';
// ----------------------------------------------------------------------
export const CalendarRoot = styled('div')(({ theme }) => ({
width: 'calc(100% + 2px)',
marginLeft: -1,
marginBottom: -1,
'& .fc': {
'--fc-border-color': varAlpha(theme.vars.palette.grey['500Channel'], 0.16),
'--fc-now-indicator-color': theme.vars.palette.error.main,
'--fc-today-bg-color': varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
'--fc-page-bg-color': theme.vars.palette.background.default,
'--fc-neutral-bg-color': theme.vars.palette.background.neutral,
'--fc-list-event-hover-bg-color': theme.vars.palette.action.hover,
'--fc-highlight-color': theme.vars.palette.action.hover,
},
'& .fc .fc-license-message': { display: 'none' },
'& .fc a': { color: theme.vars.palette.text.primary },
// Table Head
'& .fc .fc-col-header ': {
boxShadow: `inset 0 -1px 0 ${theme.vars.palette.divider}`,
'& th': { borderColor: 'transparent' },
'& .fc-col-header-cell-cushion': { ...theme.typography.subtitle2, padding: '13px 0' },
},
// List Empty
'& .fc .fc-list-empty': {
...theme.typography.h6,
backgroundColor: 'transparent',
color: theme.vars.palette.text.secondary,
},
// Event
'& .fc .fc-event': {
borderColor: 'transparent !important',
backgroundColor: 'transparent !important',
},
'& .fc .fc-event .fc-event-main': {
padding: '2px 4px',
borderRadius: 6,
backgroundColor: theme.vars.palette.common.white,
'&::before': {
top: 0,
left: 0,
width: '100%',
content: "''",
opacity: 0.24,
height: '100%',
borderRadius: 6,
position: 'absolute',
backgroundColor: 'currentColor',
transition: theme.transitions.create(['opacity']),
'&:hover': { '&::before': { opacity: 0.32 } },
},
},
'& .fc .fc-event .fc-event-main-frame': {
fontSize: 13,
lineHeight: '20px',
filter: 'brightness(0.48)',
},
'& .fc .fc-daygrid-event .fc-event-title': {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
'& .fc .fc-event .fc-event-time': {
overflow: 'unset',
fontWeight: theme.typography.fontWeightBold,
},
// Popover
'& .fc .fc-popover': {
border: 0,
overflow: 'hidden',
boxShadow: theme.vars.customShadows.dropdown,
borderRadius: theme.shape.borderRadius * 1.5,
backgroundColor: theme.vars.palette.background.paper,
},
'& .fc .fc-popover-header': {
...theme.typography.subtitle2,
padding: theme.spacing(1),
backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
},
'& .fc .fc-popover-close': {
opacity: 0.48,
transition: theme.transitions.create(['opacity']),
'&:hover': { opacity: 1 },
},
'& .fc .fc-more-popover .fc-popover-body': { padding: theme.spacing(1) },
'& .fc .fc-popover-body': {
'& .fc-daygrid-event.fc-event-start, & .fc-daygrid-event.fc-event-end': { margin: '2px 0' },
},
// Month View
'& .fc .fc-day-other .fc-daygrid-day-top': {
opacity: 1,
'& .fc-daygrid-day-number': { color: theme.vars.palette.text.disabled },
},
'& .fc .fc-daygrid-day-number': { ...theme.typography.body2, padding: theme.spacing(1, 1, 0) },
'& .fc .fc-daygrid-event': { marginTop: 4 },
'& .fc .fc-daygrid-event.fc-event-start, & .fc .fc-daygrid-event.fc-event-end': {
marginLeft: 4,
marginRight: 4,
},
'& .fc .fc-daygrid-more-link': {
...theme.typography.caption,
color: theme.vars.palette.text.secondary,
'&:hover': {
backgroundColor: 'unset',
textDecoration: 'underline',
color: theme.vars.palette.text.primary,
fontWeight: theme.typography.fontWeightMedium,
},
},
// Week & Day View
'& .fc .fc-timegrid-axis-cushion': {
...theme.typography.body2,
color: theme.vars.palette.text.secondary,
},
'& .fc .fc-timegrid-slot-label-cushion': { ...theme.typography.body2 },
// Agenda View
'& .fc-direction-ltr .fc-list-day-text, .fc-direction-rtl .fc-list-day-side-text, .fc-direction-ltr .fc-list-day-side-text, .fc-direction-rtl .fc-list-day-text':
{ ...theme.typography.subtitle2 },
'& .fc .fc-list-event': {
...theme.typography.body2,
'& .fc-list-event-time': { color: theme.vars.palette.text.secondary },
},
'& .fc .fc-list-table': { '& th, td': { borderColor: 'transparent' } },
}));

View File

@@ -0,0 +1,267 @@
import type { Theme, SxProps } from '@mui/material/styles';
import type { ICalendarEvent, ICalendarFilters } from 'src/types/calendar';
import Calendar from '@fullcalendar/react';
import listPlugin from '@fullcalendar/list/index.js';
import dayGridPlugin from '@fullcalendar/daygrid/index.js';
import { useEffect, startTransition } from 'react';
import timeGridPlugin from '@fullcalendar/timegrid/index.js';
import timelinePlugin from '@fullcalendar/timeline/index.js';
import interactionPlugin from '@fullcalendar/interaction/index.js';
import { useBoolean, useSetState } from 'minimal-shared/hooks';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import DialogTitle from '@mui/material/DialogTitle';
import { fDate, fIsAfter, fIsBetween } from 'src/utils/format-time';
import { DashboardContent } from 'src/layouts/dashboard';
import { CALENDAR_COLOR_OPTIONS } from 'src/_mock/_calendar';
import { updateEvent, useGetEvents } from 'src/actions/calendar';
import { Iconify } from 'src/components/iconify';
import { CalendarRoot } from '../styles';
import { useEvent } from '../hooks/use-event';
import { CalendarForm } from '../calendar-form';
import { useCalendar } from '../hooks/use-calendar';
import { CalendarToolbar } from '../calendar-toolbar';
import { CalendarFilters } from '../calendar-filters';
import { CalendarFiltersResult } from '../calendar-filters-result';
// ----------------------------------------------------------------------
export function CalendarView() {
const theme = useTheme();
const openFilters = useBoolean();
const { events, eventsLoading } = useGetEvents();
const filters = useSetState<ICalendarFilters>({ colors: [], startDate: null, endDate: null });
const { state: currentFilters } = filters;
const dateError = fIsAfter(currentFilters.startDate, currentFilters.endDate);
const {
calendarRef,
/********/
view,
date,
/********/
onDatePrev,
onDateNext,
onDateToday,
onDropEvent,
onChangeView,
onSelectRange,
onClickEvent,
onResizeEvent,
onInitialView,
/********/
openForm,
onOpenForm,
onCloseForm,
/********/
selectEventId,
selectedRange,
/********/
onClickEventInFilters,
} = useCalendar();
const currentEvent = useEvent(events, selectEventId, selectedRange, openForm);
useEffect(() => {
onInitialView();
}, [onInitialView]);
const canReset =
currentFilters.colors.length > 0 || (!!currentFilters.startDate && !!currentFilters.endDate);
const dataFiltered = applyFilter({
inputData: events,
filters: currentFilters,
dateError,
});
const renderResults = () => (
<CalendarFiltersResult
filters={filters}
totalResults={dataFiltered.length}
sx={{ mb: { xs: 3, md: 5 } }}
/>
);
const flexStyles: SxProps<Theme> = {
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
};
return (
<>
<DashboardContent maxWidth="xl" sx={{ ...flexStyles }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: { xs: 3, md: 5 },
}}
>
<Typography variant="h4">Calendar</Typography>
<Button
variant="contained"
startIcon={<Iconify icon="mingcute:add-line" />}
onClick={onOpenForm}
>
New event
</Button>
</Box>
{canReset && renderResults()}
<Card
sx={{
...flexStyles,
minHeight: '50vh',
}}
>
<CalendarRoot
sx={{
...flexStyles,
'.fc.fc-media-screen': { flex: '1 1 auto' },
}}
>
<CalendarToolbar
date={fDate(date)}
view={view}
canReset={canReset}
loading={eventsLoading}
onNextDate={onDateNext}
onPrevDate={onDatePrev}
onToday={onDateToday}
onChangeView={onChangeView}
onOpenFilters={openFilters.onTrue}
/>
<Calendar
weekends
editable
droppable
selectable
rerenderDelay={10}
allDayMaintainDuration
eventResizableFromStart
ref={calendarRef}
initialDate={date}
initialView={view}
dayMaxEventRows={3}
eventDisplay="block"
events={dataFiltered}
headerToolbar={false}
select={onSelectRange}
eventClick={onClickEvent}
aspectRatio={3}
eventDrop={(arg) => {
startTransition(() => {
onDropEvent(arg, updateEvent);
});
}}
eventResize={(arg) => {
startTransition(() => {
onResizeEvent(arg, updateEvent);
});
}}
plugins={[
listPlugin,
dayGridPlugin,
timelinePlugin,
timeGridPlugin,
interactionPlugin,
]}
/>
</CalendarRoot>
</Card>
</DashboardContent>
<Dialog
fullWidth
maxWidth="xs"
open={openForm}
onClose={onCloseForm}
transitionDuration={{
enter: theme.transitions.duration.shortest,
exit: theme.transitions.duration.shortest - 80,
}}
slotProps={{
paper: {
sx: {
display: 'flex',
overflow: 'hidden',
flexDirection: 'column',
'& form': {
...flexStyles,
minHeight: 0,
},
},
},
}}
>
<DialogTitle sx={{ minHeight: 76 }}>
{openForm && <> {currentEvent?.id ? 'Edit' : 'Add'} event</>}
</DialogTitle>
<CalendarForm
currentEvent={currentEvent}
colorOptions={CALENDAR_COLOR_OPTIONS}
onClose={onCloseForm}
/>
</Dialog>
<CalendarFilters
events={events}
filters={filters}
canReset={canReset}
dateError={dateError}
open={openFilters.value}
onClose={openFilters.onFalse}
onClickEvent={onClickEventInFilters}
colorOptions={CALENDAR_COLOR_OPTIONS}
/>
</>
);
}
// ----------------------------------------------------------------------
type ApplyFilterProps = {
dateError: boolean;
filters: ICalendarFilters;
inputData: ICalendarEvent[];
};
function applyFilter({ inputData, filters, dateError }: ApplyFilterProps) {
const { colors, startDate, endDate } = filters;
const stabilizedThis = inputData.map((el, index) => [el, index] as const);
inputData = stabilizedThis.map((el) => el[0]);
if (colors.length) {
inputData = inputData.filter((event) => colors.includes(event.color as string));
}
if (!dateError) {
if (startDate && endDate) {
inputData = inputData.filter((event) => fIsBetween(event.start, startDate, endDate));
}
}
return inputData;
}

View File

@@ -0,0 +1 @@
export * from './calendar-view';