init commit,
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
226
03_source/frontend/src/sections/calendar/calendar-filters.tsx
Normal file
226
03_source/frontend/src/sections/calendar/calendar-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
169
03_source/frontend/src/sections/calendar/calendar-form.tsx
Normal file
169
03_source/frontend/src/sections/calendar/calendar-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
146
03_source/frontend/src/sections/calendar/calendar-toolbar.tsx
Normal file
146
03_source/frontend/src/sections/calendar/calendar-toolbar.tsx
Normal 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()}
|
||||
</>
|
||||
);
|
||||
}
|
174
03_source/frontend/src/sections/calendar/hooks/use-calendar.ts
Normal file
174
03_source/frontend/src/sections/calendar/hooks/use-calendar.ts
Normal 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,
|
||||
};
|
||||
}
|
40
03_source/frontend/src/sections/calendar/hooks/use-event.ts
Normal file
40
03_source/frontend/src/sections/calendar/hooks/use-event.ts
Normal 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;
|
||||
}
|
137
03_source/frontend/src/sections/calendar/styles.tsx
Normal file
137
03_source/frontend/src/sections/calendar/styles.tsx
Normal 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' } },
|
||||
}));
|
267
03_source/frontend/src/sections/calendar/view/calendar-view.tsx
Normal file
267
03_source/frontend/src/sections/calendar/view/calendar-view.tsx
Normal 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;
|
||||
}
|
1
03_source/frontend/src/sections/calendar/view/index.ts
Normal file
1
03_source/frontend/src/sections/calendar/view/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './calendar-view';
|
Reference in New Issue
Block a user