init commit,
This commit is contained in:
161
03_source/frontend/src/sections/tour/tour-details-bookers.tsx
Normal file
161
03_source/frontend/src/sections/tour/tour-details-bookers.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { ITourBooker } from 'src/types/tour';
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Button from '@mui/material/Button';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & {
|
||||
bookers?: ITourBooker[];
|
||||
};
|
||||
|
||||
export function TourDetailsBookers({ bookers, sx, ...other }: Props) {
|
||||
const [approved, setApproved] = useState<string[]>([]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: string) => {
|
||||
const selected = approved.includes(item)
|
||||
? approved.filter((value) => value !== item)
|
||||
: [...approved, item];
|
||||
|
||||
setApproved(selected);
|
||||
},
|
||||
[approved]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: 'repeat(1, 1fr)',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
},
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
{bookers?.map((booker) => (
|
||||
<BookerItem
|
||||
key={booker.id}
|
||||
booker={booker}
|
||||
selected={approved.includes(booker.id)}
|
||||
onSelected={() => handleClick(booker.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Pagination count={10} sx={{ mt: { xs: 5, md: 8 }, mx: 'auto' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type BookerItemProps = {
|
||||
selected: boolean;
|
||||
booker: ITourBooker;
|
||||
onSelected: () => void;
|
||||
};
|
||||
|
||||
function BookerItem({ booker, selected, onSelected }: BookerItemProps) {
|
||||
const renderActions = () => (
|
||||
<Box sx={{ mt: 2, gap: 1, display: 'flex' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
borderRadius: 1,
|
||||
bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.08),
|
||||
'&:hover': { bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.16) },
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Iconify width={18} icon="solar:phone-bold" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
borderRadius: 1,
|
||||
bgcolor: varAlpha(theme.vars.palette.info.mainChannel, 0.08),
|
||||
'&:hover': { bgcolor: varAlpha(theme.vars.palette.info.mainChannel, 0.16) },
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Iconify width={18} icon="solar:chat-round-dots-bold" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
borderRadius: 1,
|
||||
bgcolor: varAlpha(theme.vars.palette.primary.mainChannel, 0.08),
|
||||
'&:hover': { bgcolor: varAlpha(theme.vars.palette.primary.mainChannel, 0.16) },
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Iconify width={18} icon="solar:letter-bold" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={booker.id} sx={{ p: 3, gap: 2, display: 'flex' }}>
|
||||
<Avatar alt={booker.name} src={booker.avatarUrl} sx={{ width: 48, height: 48 }} />
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: '1 1 auto' }}>
|
||||
<ListItemText
|
||||
primary={booker.name}
|
||||
secondary={
|
||||
<Box sx={{ gap: 0.5, display: 'flex', alignItems: 'center' }}>
|
||||
<Iconify icon="solar:users-group-rounded-bold" width={16} />
|
||||
{booker.guests} guests
|
||||
</Box>
|
||||
}
|
||||
slotProps={{
|
||||
primary: { noWrap: true },
|
||||
secondary: {
|
||||
sx: { mt: 0.5, typography: 'caption', color: 'text.disabled' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{renderActions()}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant={selected ? 'text' : 'outlined'}
|
||||
color={selected ? 'success' : 'inherit'}
|
||||
startIcon={
|
||||
selected ? <Iconify width={18} icon="eva:checkmark-fill" sx={{ mr: -0.75 }} /> : null
|
||||
}
|
||||
onClick={onSelected}
|
||||
>
|
||||
{selected ? 'Approved' : 'Approve'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
278
03_source/frontend/src/sections/tour/tour-details-content.tsx
Normal file
278
03_source/frontend/src/sections/tour/tour-details-content.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
|
||||
import { fDate } from 'src/utils/format-time';
|
||||
|
||||
import { TOUR_SERVICE_OPTIONS } from 'src/_mock';
|
||||
|
||||
import { Image } from 'src/components/image';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Markdown } from 'src/components/markdown';
|
||||
import { Lightbox, useLightBox } from 'src/components/lightbox';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
tour?: ITourItem;
|
||||
};
|
||||
|
||||
export function TourDetailsContent({ tour }: Props) {
|
||||
const slides = tour?.images.map((slide) => ({ src: slide })) || [];
|
||||
|
||||
const {
|
||||
selected: selectedImage,
|
||||
open: openLightbox,
|
||||
onOpen: handleOpenLightbox,
|
||||
onClose: handleCloseLightbox,
|
||||
} = useLightBox(slides);
|
||||
|
||||
const renderGallery = () => (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 1,
|
||||
display: 'grid',
|
||||
mb: { xs: 3, md: 5 },
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
alt={slides[0].src}
|
||||
src={slides[0].src}
|
||||
ratio="1/1"
|
||||
onClick={() => handleOpenLightbox(slides[0].src)}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
transition: theme.transitions.create('opacity'),
|
||||
'&:hover': { opacity: 0.8 },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box sx={{ gap: 1, display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
{slides.slice(1, 5).map((slide) => (
|
||||
<Image
|
||||
key={slide.src}
|
||||
alt={slide.src}
|
||||
src={slide.src}
|
||||
ratio="1/1"
|
||||
onClick={() => handleOpenLightbox(slide.src)}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
transition: theme.transitions.create('opacity'),
|
||||
'&:hover': { opacity: 0.8 },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Lightbox
|
||||
index={selectedImage}
|
||||
slides={slides}
|
||||
open={openLightbox}
|
||||
close={handleCloseLightbox}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderHead = () => (
|
||||
<>
|
||||
<Box sx={{ mb: 3, display: 'flex' }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
{tour?.name}
|
||||
</Typography>
|
||||
|
||||
<IconButton>
|
||||
<Iconify icon="solar:share-bold" />
|
||||
</IconButton>
|
||||
|
||||
<Checkbox
|
||||
defaultChecked
|
||||
color="error"
|
||||
icon={<Iconify icon="solar:heart-outline" />}
|
||||
checkedIcon={<Iconify icon="solar:heart-bold" />}
|
||||
slotProps={{
|
||||
input: {
|
||||
id: 'favorite-checkbox',
|
||||
'aria-label': 'Favorite checkbox',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
typography: 'body2',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="eva:star-fill" sx={{ color: 'warning.main' }} />
|
||||
<Box component="span" sx={{ typography: 'subtitle2' }}>
|
||||
{tour?.ratingNumber}
|
||||
</Box>
|
||||
|
||||
<Link sx={{ color: 'text.secondary' }}>(234 reviews)</Link>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
typography: 'body2',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="mingcute:location-fill" sx={{ color: 'error.main' }} />
|
||||
{tour?.destination}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
typography: 'subtitle2',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="solar:flag-bold" sx={{ color: 'info.main' }} />
|
||||
<Box component="span" sx={{ typography: 'body2', color: 'text.secondary' }}>
|
||||
Guide by
|
||||
</Box>
|
||||
|
||||
{tour?.tourGuides.map((tourGuide) => tourGuide.name).join(', ')}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderOverview = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: 'Available',
|
||||
value: `${fDate(tour?.available.startDate)} - ${fDate(tour?.available.endDate)}`,
|
||||
icon: <Iconify icon="solar:calendar-date-bold" />,
|
||||
},
|
||||
{
|
||||
label: 'Contact name',
|
||||
value: tour?.tourGuides.map((tourGuide) => tourGuide.phoneNumber).join(', '),
|
||||
icon: <Iconify icon="solar:user-rounded-bold" />,
|
||||
},
|
||||
{
|
||||
label: 'Durations',
|
||||
value: tour?.durations,
|
||||
icon: <Iconify icon="solar:clock-circle-bold" />,
|
||||
},
|
||||
{
|
||||
label: 'Contact phone',
|
||||
value: tour?.tourGuides.map((tourGuide) => tourGuide.name).join(', '),
|
||||
icon: <Iconify icon="solar:phone-bold" />,
|
||||
},
|
||||
].map((item) => (
|
||||
<Box key={item.label} sx={{ gap: 1.5, display: 'flex' }}>
|
||||
{item.icon}
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
secondary={item.value}
|
||||
slotProps={{
|
||||
primary: {
|
||||
sx: { typography: 'body2', color: 'text.secondary' },
|
||||
},
|
||||
secondary: {
|
||||
sx: { mt: 0.5, color: 'text.primary', typography: 'subtitle2' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<>
|
||||
<Markdown children={tour?.content} />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Services
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
rowGap: 2,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{TOUR_SERVICE_OPTIONS.map((service) => (
|
||||
<Box
|
||||
key={service.label}
|
||||
sx={{
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...(tour?.services.includes(service.label) && { color: 'text.disabled' }),
|
||||
}}
|
||||
>
|
||||
<Iconify
|
||||
icon="eva:checkmark-circle-2-outline"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
...(tour?.services.includes(service.label) && { color: 'text.disabled' }),
|
||||
}}
|
||||
/>
|
||||
{service.label}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderGallery()}
|
||||
|
||||
<Box sx={{ mx: 'auto', maxWidth: 720 }}>
|
||||
{renderHead()}
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed', my: 5 }} />
|
||||
|
||||
{renderOverview()}
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed', mt: 5, mb: 2 }} />
|
||||
|
||||
{renderContent()}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
112
03_source/frontend/src/sections/tour/tour-details-toolbar.tsx
Normal file
112
03_source/frontend/src/sections/tour/tour-details-toolbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & {
|
||||
backHref: string;
|
||||
editHref: string;
|
||||
liveHref: string;
|
||||
publish: string;
|
||||
onChangePublish: (newValue: string) => void;
|
||||
publishOptions: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
export function TourDetailsToolbar({
|
||||
sx,
|
||||
publish,
|
||||
backHref,
|
||||
editHref,
|
||||
liveHref,
|
||||
publishOptions,
|
||||
onChangePublish,
|
||||
...other
|
||||
}: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'top-right' } }}
|
||||
>
|
||||
<MenuList>
|
||||
{publishOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={option.value === publish}
|
||||
onClick={() => {
|
||||
menuActions.onClose();
|
||||
onChangePublish(option.value);
|
||||
}}
|
||||
>
|
||||
{option.value === 'published' && <Iconify icon="eva:cloud-upload-fill" />}
|
||||
{option.value === 'draft' && <Iconify icon="solar:file-text-bold" />}
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={[{ mb: 2, gap: 1.5, display: 'flex' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
{...other}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={backHref}
|
||||
startIcon={<Iconify icon="eva:arrow-ios-back-fill" width={16} />}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{publish === 'published' && (
|
||||
<Tooltip title="Go Live">
|
||||
<IconButton component={RouterLink} href={liveHref}>
|
||||
<Iconify icon="eva:external-link-fill" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Edit">
|
||||
<IconButton component={RouterLink} href={editHref}>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="contained"
|
||||
loading={!publish}
|
||||
loadingIndicator="Loading…"
|
||||
endIcon={<Iconify icon="eva:arrow-ios-downward-fill" />}
|
||||
onClick={menuActions.onOpen}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{publish}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
102
03_source/frontend/src/sections/tour/tour-filters-result.tsx
Normal file
102
03_source/frontend/src/sections/tour/tour-filters-result.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import type { ITourGuide, ITourFilters } from 'src/types/tour';
|
||||
import type { FiltersResultProps } from 'src/components/filters-result';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
|
||||
import { fDateRangeShortLabel } from 'src/utils/format-time';
|
||||
|
||||
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = FiltersResultProps & {
|
||||
filters: UseSetStateReturn<ITourFilters>;
|
||||
};
|
||||
|
||||
export function TourFiltersResult({ filters, totalResults, sx }: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleRemoveServices = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.services.filter((item) => item !== inputValue);
|
||||
|
||||
updateFilters({ services: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.services]
|
||||
);
|
||||
|
||||
const handleRemoveAvailable = useCallback(() => {
|
||||
updateFilters({ startDate: null, endDate: null });
|
||||
}, [updateFilters]);
|
||||
|
||||
const handleRemoveTourGuide = useCallback(
|
||||
(inputValue: ITourGuide) => {
|
||||
const newValue = currentFilters.tourGuides.filter((item) => item.name !== inputValue.name);
|
||||
|
||||
updateFilters({ tourGuides: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.tourGuides]
|
||||
);
|
||||
|
||||
const handleRemoveDestination = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.destination.filter((item) => item !== inputValue);
|
||||
|
||||
updateFilters({ destination: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.destination]
|
||||
);
|
||||
|
||||
return (
|
||||
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
|
||||
<FiltersBlock
|
||||
label="Available:"
|
||||
isShow={Boolean(currentFilters.startDate && currentFilters.endDate)}
|
||||
>
|
||||
<Chip
|
||||
{...chipProps}
|
||||
label={fDateRangeShortLabel(currentFilters.startDate, currentFilters.endDate)}
|
||||
onDelete={handleRemoveAvailable}
|
||||
/>
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Services:" isShow={!!currentFilters.services.length}>
|
||||
{currentFilters.services.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item}
|
||||
label={item}
|
||||
onDelete={() => handleRemoveServices(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Tour guide:" isShow={!!currentFilters.tourGuides.length}>
|
||||
{currentFilters.tourGuides.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item.id}
|
||||
avatar={<Avatar alt={item.name} src={item.avatarUrl} />}
|
||||
label={item.name}
|
||||
onDelete={() => handleRemoveTourGuide(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Destination:" isShow={!!currentFilters.destination.length}>
|
||||
{currentFilters.destination.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item}
|
||||
label={item}
|
||||
onDelete={() => handleRemoveDestination(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
</FiltersResult>
|
||||
);
|
||||
}
|
277
03_source/frontend/src/sections/tour/tour-filters.tsx
Normal file
277
03_source/frontend/src/sections/tour/tour-filters.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { IDatePickerControl } from 'src/types/common';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import type { ITourGuide, ITourFilters } from 'src/types/tour';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import Button from '@mui/material/Button';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Scrollbar } from 'src/components/scrollbar';
|
||||
import { CountrySelect } from 'src/components/country-select';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
canReset: boolean;
|
||||
dateError: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
filters: UseSetStateReturn<ITourFilters>;
|
||||
options: {
|
||||
services: string[];
|
||||
tourGuides: ITourGuide[];
|
||||
};
|
||||
};
|
||||
|
||||
export function TourFilters({
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
filters,
|
||||
options,
|
||||
canReset,
|
||||
dateError,
|
||||
}: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleFilterServices = useCallback(
|
||||
(newValue: string) => {
|
||||
const checked = currentFilters.services.includes(newValue)
|
||||
? currentFilters.services.filter((value) => value !== newValue)
|
||||
: [...currentFilters.services, newValue];
|
||||
|
||||
updateFilters({ services: checked });
|
||||
},
|
||||
[updateFilters, currentFilters.services]
|
||||
);
|
||||
|
||||
const handleFilterStartDate = useCallback(
|
||||
(newValue: IDatePickerControl) => {
|
||||
updateFilters({ startDate: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterEndDate = useCallback(
|
||||
(newValue: IDatePickerControl) => {
|
||||
updateFilters({ endDate: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterDestination = useCallback(
|
||||
(newValue: string[]) => {
|
||||
updateFilters({ destination: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterTourGuide = useCallback(
|
||||
(newValue: ITourGuide[]) => {
|
||||
updateFilters({ tourGuides: 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 renderDateRange = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||
Durations
|
||||
</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 renderDestination = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||
Destination
|
||||
</Typography>
|
||||
|
||||
<CountrySelect
|
||||
id="multiple-destinations"
|
||||
multiple
|
||||
fullWidth
|
||||
placeholder={currentFilters.destination.length ? '+ Destination' : 'Select Destination'}
|
||||
value={currentFilters.destination}
|
||||
onChange={(event, newValue) => handleFilterDestination(newValue)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderTourGuide = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||
Tour guide
|
||||
</Typography>
|
||||
|
||||
<Autocomplete
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
options={options.tourGuides}
|
||||
value={currentFilters.tourGuides}
|
||||
onChange={(event, newValue) => handleFilterTourGuide(newValue)}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderInput={(params) => <TextField placeholder="Select Tour Guides" {...params} />}
|
||||
renderOption={(props, tourGuide) => (
|
||||
<li {...props} key={tourGuide.id}>
|
||||
<Avatar
|
||||
key={tourGuide.id}
|
||||
alt={tourGuide.avatarUrl}
|
||||
src={tourGuide.avatarUrl}
|
||||
sx={{
|
||||
mr: 1,
|
||||
width: 24,
|
||||
height: 24,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{tourGuide.name}
|
||||
</li>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((tourGuide, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={tourGuide.id}
|
||||
size="small"
|
||||
variant="soft"
|
||||
label={tourGuide.name}
|
||||
avatar={<Avatar alt={tourGuide.name} src={tourGuide.avatarUrl} />}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderServices = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Services
|
||||
</Typography>
|
||||
|
||||
{options.services.map((option) => (
|
||||
<FormControlLabel
|
||||
key={option}
|
||||
label={option}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={currentFilters.services.includes(option)}
|
||||
onClick={() => handleFilterServices(option)}
|
||||
slotProps={{
|
||||
input: { id: `${option}-checkbox` },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disableRipple
|
||||
color="inherit"
|
||||
endIcon={
|
||||
<Badge color="error" variant="dot" invisible={!canReset}>
|
||||
<Iconify icon="ic:round-filter-list" />
|
||||
</Badge>
|
||||
}
|
||||
onClick={onOpen}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slotProps={{
|
||||
backdrop: { invisible: true },
|
||||
paper: { sx: { width: 320 } },
|
||||
}}
|
||||
>
|
||||
{renderHead()}
|
||||
|
||||
<Scrollbar sx={{ px: 2.5, py: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
{renderDateRange()}
|
||||
{renderDestination()}
|
||||
{renderTourGuide()}
|
||||
{renderServices()}
|
||||
</Stack>
|
||||
</Scrollbar>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
218
03_source/frontend/src/sections/tour/tour-item.tsx
Normal file
218
03_source/frontend/src/sections/tour/tour-item.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
import type { CardProps } from '@mui/material/Card';
|
||||
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import Card from '@mui/material/Card';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
|
||||
import { fCurrency } from 'src/utils/format-number';
|
||||
import { fDateTime, fDateRangeShortLabel } from 'src/utils/format-time';
|
||||
|
||||
import { Image } from 'src/components/image';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = CardProps & {
|
||||
tour: ITourItem;
|
||||
editHref: string;
|
||||
detailsHref: string;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function TourItem({ tour, editHref, detailsHref, onDelete, sx, ...other }: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const renderRating = () => (
|
||||
<Box
|
||||
sx={{
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 9,
|
||||
display: 'flex',
|
||||
borderRadius: 1,
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
p: '2px 6px 2px 4px',
|
||||
typography: 'subtitle2',
|
||||
bgcolor: 'warning.lighter',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="eva:star-fill" sx={{ color: 'warning.main', mr: 0.25 }} /> {tour.ratingNumber}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderPrice = () => (
|
||||
<Box
|
||||
sx={{
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 9,
|
||||
display: 'flex',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'grey.800',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
p: '2px 6px 2px 4px',
|
||||
color: 'common.white',
|
||||
typography: 'subtitle2',
|
||||
}}
|
||||
>
|
||||
{!!tour.priceSale && (
|
||||
<Box component="span" sx={{ color: 'grey.500', mr: 0.25, textDecoration: 'line-through' }}>
|
||||
{fCurrency(tour.priceSale)}
|
||||
</Box>
|
||||
)}
|
||||
{fCurrency(tour.price)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderImages = () => (
|
||||
<Box sx={{ p: 1, gap: 0.5, display: 'flex' }}>
|
||||
<Box sx={{ flexGrow: 1, position: 'relative' }}>
|
||||
{renderPrice()}
|
||||
{renderRating()}
|
||||
<Image
|
||||
alt={tour.images[0]}
|
||||
src={tour.images[0]}
|
||||
sx={{ width: 1, height: 164, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 0.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<Image
|
||||
alt={tour.images[1]}
|
||||
src={tour.images[1]}
|
||||
ratio="1/1"
|
||||
sx={{ borderRadius: 1, width: 80, height: 80 }}
|
||||
/>
|
||||
<Image
|
||||
alt={tour.images[2]}
|
||||
src={tour.images[2]}
|
||||
ratio="1/1"
|
||||
sx={{ borderRadius: 1, width: 80, height: 80 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderTexts = () => (
|
||||
<ListItemText
|
||||
sx={[(theme) => ({ p: theme.spacing(2.5, 2.5, 2, 2.5) })]}
|
||||
primary={`Posted date: ${fDateTime(tour.createdAt)}`}
|
||||
secondary={
|
||||
<Link component={RouterLink} href={detailsHref} color="inherit">
|
||||
{tour.name}
|
||||
</Link>
|
||||
}
|
||||
slotProps={{
|
||||
primary: {
|
||||
sx: { typography: 'caption', color: 'text.disabled' },
|
||||
},
|
||||
secondary: {
|
||||
noWrap: true,
|
||||
component: 'h6',
|
||||
sx: { mt: 1, color: 'text.primary', typography: 'subtitle1' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderInfo = () => (
|
||||
<Box
|
||||
sx={[
|
||||
(theme) => ({
|
||||
gap: 1.5,
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
p: theme.spacing(0, 2.5, 2.5, 2.5),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<IconButton onClick={menuActions.onOpen} sx={{ position: 'absolute', bottom: 20, right: 8 }}>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
|
||||
{[
|
||||
{
|
||||
icon: <Iconify icon="mingcute:location-fill" sx={{ color: 'error.main' }} />,
|
||||
label: tour.destination,
|
||||
},
|
||||
{
|
||||
icon: <Iconify icon="solar:clock-circle-bold" sx={{ color: 'info.main' }} />,
|
||||
label: fDateRangeShortLabel(tour.available.startDate, tour.available.endDate),
|
||||
},
|
||||
{
|
||||
icon: <Iconify icon="solar:users-group-rounded-bold" sx={{ color: 'primary.main' }} />,
|
||||
label: `${tour.bookers.length} Booked`,
|
||||
},
|
||||
].map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={[{ gap: 0.5, display: 'flex', typography: 'body2', alignItems: 'center' }]}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||
>
|
||||
<MenuList>
|
||||
<li>
|
||||
<MenuItem component={RouterLink} href={detailsHref} onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:eye-bold" />
|
||||
View
|
||||
</MenuItem>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<MenuItem component={RouterLink} href={editHref} onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
Edit
|
||||
</MenuItem>
|
||||
</li>
|
||||
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
menuActions.onClose();
|
||||
onDelete();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={sx} {...other}>
|
||||
{renderImages()}
|
||||
{renderTexts()}
|
||||
{renderInfo()}
|
||||
</Card>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
54
03_source/frontend/src/sections/tour/tour-list.tsx
Normal file
54
03_source/frontend/src/sections/tour/tour-list.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Pagination, { paginationClasses } from '@mui/material/Pagination';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { TourItem } from './tour-item';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
tours: ITourItem[];
|
||||
};
|
||||
|
||||
export function TourList({ tours }: Props) {
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
console.info('DELETE', id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{tours.map((tour) => (
|
||||
<TourItem
|
||||
key={tour.id}
|
||||
tour={tour}
|
||||
editHref={paths.dashboard.tour.edit(tour.id)}
|
||||
detailsHref={paths.dashboard.tour.details(tour.id)}
|
||||
onDelete={() => handleDelete(tour.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{tours.length > 8 && (
|
||||
<Pagination
|
||||
count={8}
|
||||
sx={{
|
||||
mt: { xs: 5, md: 8 },
|
||||
[`& .${paginationClasses.ul}`]: { justifyContent: 'center' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
339
03_source/frontend/src/sections/tour/tour-new-edit-form.tsx
Normal file
339
03_source/frontend/src/sections/tour/tour-new-edit-form.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { ITourItem, ITourGuide } from 'src/types/tour';
|
||||
|
||||
import { z as zod } from 'zod';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useBoolean } from 'minimal-shared/hooks';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Card from '@mui/material/Card';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
|
||||
import { fIsAfter } from 'src/utils/format-time';
|
||||
|
||||
import { _tags, _tourGuides, TOUR_SERVICE_OPTIONS } from 'src/_mock';
|
||||
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Form, Field, schemaHelper } from 'src/components/hook-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type NewTourSchemaType = zod.infer<typeof NewTourSchema>;
|
||||
|
||||
export const NewTourSchema = zod
|
||||
.object({
|
||||
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||
content: schemaHelper
|
||||
.editor()
|
||||
.min(100, { message: 'Content must be at least 100 characters' })
|
||||
.max(500, { message: 'Content must be less than 500 characters' }),
|
||||
images: schemaHelper.files({ message: 'Images is required!' }),
|
||||
tourGuides: zod
|
||||
.array(
|
||||
zod.object({
|
||||
id: zod.string(),
|
||||
name: zod.string(),
|
||||
avatarUrl: zod.string(),
|
||||
phoneNumber: zod.string(),
|
||||
})
|
||||
)
|
||||
.min(1, { message: 'Must have at least 1 guide!' }),
|
||||
available: zod.object({
|
||||
startDate: schemaHelper.date({ message: { required: 'Start date is required!' } }),
|
||||
endDate: schemaHelper.date({ message: { required: 'End date is required!' } }),
|
||||
}),
|
||||
durations: zod.string().min(1, { message: 'Durations is required!' }),
|
||||
destination: schemaHelper.nullableInput(
|
||||
zod.string().min(1, { message: 'Destination is required!' }),
|
||||
{
|
||||
// message for null value
|
||||
message: 'Destination is required!',
|
||||
}
|
||||
),
|
||||
services: zod.string().array().min(2, { message: 'Must have at least 2 items!' }),
|
||||
tags: zod.string().array().min(2, { message: 'Must have at least 2 items!' }),
|
||||
})
|
||||
.refine((data) => !fIsAfter(data.available.startDate, data.available.endDate), {
|
||||
message: 'End date cannot be earlier than start date!',
|
||||
path: ['available.endDate'],
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
currentTour?: ITourItem;
|
||||
};
|
||||
|
||||
export function TourNewEditForm({ currentTour }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const openDetails = useBoolean(true);
|
||||
const openProperties = useBoolean(true);
|
||||
|
||||
const defaultValues: NewTourSchemaType = {
|
||||
name: '',
|
||||
content: '',
|
||||
images: [],
|
||||
tourGuides: [],
|
||||
available: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
durations: '',
|
||||
destination: '',
|
||||
services: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const methods = useForm<NewTourSchemaType>({
|
||||
mode: 'all',
|
||||
resolver: zodResolver(NewTourSchema),
|
||||
defaultValues,
|
||||
values: currentTour,
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const values = watch();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
reset();
|
||||
toast.success(currentTour ? 'Update success!' : 'Create success!');
|
||||
router.push(paths.dashboard.tour.root);
|
||||
console.info('DATA', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFile = useCallback(
|
||||
(inputFile: File | string) => {
|
||||
const filtered = values.images && values.images?.filter((file) => file !== inputFile);
|
||||
setValue('images', filtered, { shouldValidate: true });
|
||||
},
|
||||
[setValue, values.images]
|
||||
);
|
||||
|
||||
const handleRemoveAllFiles = useCallback(() => {
|
||||
setValue('images', [], { shouldValidate: true });
|
||||
}, [setValue]);
|
||||
|
||||
const renderCollapseButton = (value: boolean, onToggle: () => void) => (
|
||||
<IconButton onClick={onToggle}>
|
||||
<Iconify icon={value ? 'eva:arrow-ios-downward-fill' : 'eva:arrow-ios-forward-fill'} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const renderDetails = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Details"
|
||||
subheader="Title, short description, image..."
|
||||
action={renderCollapseButton(openDetails.value, openDetails.onToggle)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Collapse in={openDetails.value}>
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Name</Typography>
|
||||
<Field.Text name="name" placeholder="Ex: Adventure Seekers Expedition..." />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Content</Typography>
|
||||
<Field.Editor name="content" sx={{ maxHeight: 480 }} />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Images</Typography>
|
||||
<Field.Upload
|
||||
multiple
|
||||
thumbnail
|
||||
name="images"
|
||||
maxSize={3145728}
|
||||
onRemove={handleRemoveFile}
|
||||
onRemoveAll={handleRemoveAllFiles}
|
||||
onUpload={() => console.info('ON UPLOAD')}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderProperties = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Properties"
|
||||
subheader="Additional functions and attributes..."
|
||||
action={renderCollapseButton(openProperties.value, openProperties.onToggle)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Collapse in={openProperties.value}>
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<div>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||
Tour guide
|
||||
</Typography>
|
||||
|
||||
<Field.Autocomplete
|
||||
multiple
|
||||
name="tourGuides"
|
||||
placeholder="+ Tour Guides"
|
||||
disableCloseOnSelect
|
||||
options={_tourGuides}
|
||||
getOptionLabel={(option) => (option as ITourGuide).name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderOption={(props, tourGuide) => (
|
||||
<li {...props} key={tourGuide.id}>
|
||||
<Avatar
|
||||
key={tourGuide.id}
|
||||
alt={tourGuide.avatarUrl}
|
||||
src={tourGuide.avatarUrl}
|
||||
sx={{
|
||||
mr: 1,
|
||||
width: 24,
|
||||
height: 24,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{tourGuide.name}
|
||||
</li>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((tourGuide, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={tourGuide.id}
|
||||
size="small"
|
||||
variant="soft"
|
||||
label={tourGuide.name}
|
||||
avatar={<Avatar alt={tourGuide.name} src={tourGuide.avatarUrl} />}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Available</Typography>
|
||||
<Box sx={{ gap: 2, display: 'flex', flexDirection: { xs: 'column', md: 'row' } }}>
|
||||
<Field.DatePicker name="available.startDate" label="Start date" />
|
||||
<Field.DatePicker name="available.endDate" label="End date" />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Duration</Typography>
|
||||
<Field.Text name="durations" placeholder="Ex: 2 days, 4 days 3 nights..." />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Destination</Typography>
|
||||
<Field.CountrySelect fullWidth name="destination" placeholder="+ Destination" />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Services</Typography>
|
||||
<Field.MultiCheckbox
|
||||
name="services"
|
||||
options={TOUR_SERVICE_OPTIONS}
|
||||
sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Tags</Typography>
|
||||
<Field.Autocomplete
|
||||
name="tags"
|
||||
placeholder="+ Tags"
|
||||
multiple
|
||||
freeSolo
|
||||
disableCloseOnSelect
|
||||
options={_tags.map((option) => option)}
|
||||
getOptionLabel={(option) => option}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option}>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
label={option}
|
||||
size="small"
|
||||
color="info"
|
||||
variant="soft"
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderActions = () => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControlLabel
|
||||
label="Publish"
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked
|
||||
slotProps={{
|
||||
input: { id: 'publish-switch' },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{ flexGrow: 1, pl: 3 }}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="contained" size="large" loading={isSubmitting} sx={{ ml: 2 }}>
|
||||
{!currentTour ? 'Create tour' : 'Save changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<Stack spacing={{ xs: 3, md: 5 }} sx={{ mx: 'auto', maxWidth: { xs: 720, xl: 880 } }}>
|
||||
{renderDetails()}
|
||||
{renderProperties()}
|
||||
{renderActions()}
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
192
03_source/frontend/src/sections/tour/tour-search.tsx
Normal file
192
03_source/frontend/src/sections/tour/tour-search.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
import type { Theme, SxProps } from '@mui/material/styles';
|
||||
|
||||
import parse from 'autosuggest-highlight/parse';
|
||||
import match from 'autosuggest-highlight/match';
|
||||
import { useDebounce } from 'minimal-shared/hooks';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Link, { linkClasses } from '@mui/material/Link';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Autocomplete, { autocompleteClasses, createFilterOptions } from '@mui/material/Autocomplete';
|
||||
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
|
||||
import { _tours } from 'src/_mock';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { SearchNotFound } from 'src/components/search-not-found';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
sx?: SxProps<Theme>;
|
||||
redirectPath: (id: string) => string;
|
||||
};
|
||||
|
||||
export function TourSearch({ redirectPath, sx }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<ITourItem | null>(null);
|
||||
|
||||
const debouncedQuery = useDebounce(searchQuery);
|
||||
const { searchResults: options, searchLoading: loading } = useSearchData(debouncedQuery);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(item: ITourItem | null) => {
|
||||
setSelectedItem(item);
|
||||
if (item) {
|
||||
router.push(redirectPath(item.id));
|
||||
}
|
||||
},
|
||||
[router, redirectPath]
|
||||
);
|
||||
|
||||
const filterOptions = createFilterOptions({
|
||||
matchFrom: 'any',
|
||||
stringify: (option: ITourItem) => `${option.name} ${option.destination}`,
|
||||
});
|
||||
|
||||
const paperStyles: SxProps<Theme> = {
|
||||
width: 320,
|
||||
[`& .${autocompleteClasses.listbox}`]: {
|
||||
[`& .${autocompleteClasses.option}`]: {
|
||||
p: 0,
|
||||
[`& .${linkClasses.root}`]: {
|
||||
p: 0.75,
|
||||
gap: 1.5,
|
||||
width: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
autoHighlight
|
||||
popupIcon={null}
|
||||
loading={loading}
|
||||
options={options}
|
||||
value={selectedItem}
|
||||
filterOptions={filterOptions}
|
||||
onChange={(event, newValue) => handleChange(newValue)}
|
||||
onInputChange={(event, newValue) => setSearchQuery(newValue)}
|
||||
getOptionLabel={(option) => option.name}
|
||||
noOptionsText={<SearchNotFound query={debouncedQuery} />}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
slotProps={{ paper: { sx: paperStyles } }}
|
||||
sx={[{ width: { xs: 1, sm: 260 } }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="Search..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Iconify icon="eva:search-fill" sx={{ ml: 1, color: 'text.disabled' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? <CircularProgress size={18} color="inherit" sx={{ mr: -3 }} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, tour, { inputValue }) => {
|
||||
const matches = match(tour.name, inputValue);
|
||||
const parts = parse(tour.name, matches);
|
||||
|
||||
return (
|
||||
<li {...props} key={tour.id}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
href={redirectPath(tour.id)}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
>
|
||||
<Avatar
|
||||
key={tour.id}
|
||||
alt={tour.name}
|
||||
src={tour.images[0]}
|
||||
variant="rounded"
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
flexShrink: 0,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div key={inputValue}>
|
||||
{parts.map((part, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
component="span"
|
||||
color={part.highlight ? 'primary' : 'textPrimary'}
|
||||
sx={{
|
||||
typography: 'body2',
|
||||
fontWeight: part.highlight ? 'fontWeightSemiBold' : 'fontWeightMedium',
|
||||
}}
|
||||
>
|
||||
{part.text}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function useSearchData(searchQuery: string) {
|
||||
const [searchResults, setSearchResults] = useState<ITourItem[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
|
||||
const fetchSearchResults = useCallback(async () => {
|
||||
setSearchLoading(true);
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const results = _tours.filter(({ name, destination }) =>
|
||||
[name, destination].some((field) =>
|
||||
field?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
fetchSearchResults();
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [fetchSearchResults, searchQuery]);
|
||||
|
||||
return { searchResults, searchLoading };
|
||||
}
|
73
03_source/frontend/src/sections/tour/tour-sort.tsx
Normal file
73
03_source/frontend/src/sections/tour/tour-sort.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
sort: string;
|
||||
onSort: (newValue: string) => void;
|
||||
sortOptions: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function TourSort({ sort, onSort, sortOptions }: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
>
|
||||
<MenuList>
|
||||
{sortOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={option.value === sort}
|
||||
onClick={() => {
|
||||
menuActions.onClose();
|
||||
onSort(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disableRipple
|
||||
color="inherit"
|
||||
onClick={menuActions.onOpen}
|
||||
endIcon={
|
||||
<Iconify
|
||||
icon={menuActions.open ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'}
|
||||
/>
|
||||
}
|
||||
sx={{ fontWeight: 'fontWeightSemiBold' }}
|
||||
>
|
||||
Sort by:
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ ml: 0.5, fontWeight: 'fontWeightBold', textTransform: 'capitalize' }}
|
||||
>
|
||||
{sort}
|
||||
</Box>
|
||||
</Button>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
7
03_source/frontend/src/sections/tour/view/index.ts
Normal file
7
03_source/frontend/src/sections/tour/view/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './tour-list-view';
|
||||
|
||||
export * from './tour-edit-view';
|
||||
|
||||
export * from './tour-create-view';
|
||||
|
||||
export * from './tour-details-view';
|
@@ -0,0 +1,27 @@
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
|
||||
import { TourNewEditForm } from '../tour-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function TourCreateView() {
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Create a new tour"
|
||||
links={[
|
||||
{ name: 'Dashboard', href: paths.dashboard.root },
|
||||
{ name: 'Tour', href: paths.dashboard.tour.root },
|
||||
{ name: 'New tour' },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<TourNewEditForm />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,71 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTabs } from 'minimal-shared/hooks';
|
||||
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { TOUR_DETAILS_TABS, TOUR_PUBLISH_OPTIONS } from 'src/_mock';
|
||||
|
||||
import { Label } from 'src/components/label';
|
||||
|
||||
import { TourDetailsContent } from '../tour-details-content';
|
||||
import { TourDetailsBookers } from '../tour-details-bookers';
|
||||
import { TourDetailsToolbar } from '../tour-details-toolbar';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
tour?: ITourItem;
|
||||
};
|
||||
|
||||
export function TourDetailsView({ tour }: Props) {
|
||||
const [publish, setPublish] = useState(tour?.publish);
|
||||
|
||||
const tabs = useTabs('content');
|
||||
|
||||
const handleChangePublish = useCallback((newValue: string) => {
|
||||
setPublish(newValue);
|
||||
}, []);
|
||||
|
||||
const renderToolbar = () => (
|
||||
<TourDetailsToolbar
|
||||
backHref={paths.dashboard.tour.root}
|
||||
editHref={paths.dashboard.tour.edit(`${tour?.id}`)}
|
||||
liveHref="#"
|
||||
publish={publish || ''}
|
||||
onChangePublish={handleChangePublish}
|
||||
publishOptions={TOUR_PUBLISH_OPTIONS}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTabs = () => (
|
||||
<Tabs value={tabs.value} onChange={tabs.onChange} sx={{ mb: { xs: 3, md: 5 } }}>
|
||||
{TOUR_DETAILS_TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
iconPosition="end"
|
||||
value={tab.value}
|
||||
label={tab.label}
|
||||
icon={
|
||||
tab.value === 'bookers' ? <Label variant="filled">{tour?.bookers.length}</Label> : ''
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
{renderToolbar()}
|
||||
|
||||
{renderTabs()}
|
||||
{tabs.value === 'content' && <TourDetailsContent tour={tour} />}
|
||||
{tabs.value === 'bookers' && <TourDetailsBookers bookers={tour?.bookers} />}
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
34
03_source/frontend/src/sections/tour/view/tour-edit-view.tsx
Normal file
34
03_source/frontend/src/sections/tour/view/tour-edit-view.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ITourItem } from 'src/types/tour';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
|
||||
import { TourNewEditForm } from '../tour-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
tour?: ITourItem;
|
||||
};
|
||||
|
||||
export function TourEditView({ tour }: Props) {
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Edit"
|
||||
backHref={paths.dashboard.tour.root}
|
||||
links={[
|
||||
{ name: 'Dashboard', href: paths.dashboard.root },
|
||||
{ name: 'Tour', href: paths.dashboard.tour.root },
|
||||
{ name: tour?.name },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<TourNewEditForm currentTour={tour} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
186
03_source/frontend/src/sections/tour/view/tour-list-view.tsx
Normal file
186
03_source/frontend/src/sections/tour/view/tour-list-view.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { ITourItem, ITourFilters } from 'src/types/tour';
|
||||
|
||||
import { orderBy } from 'es-toolkit';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useBoolean, useSetState } from 'minimal-shared/hooks';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
|
||||
import { fIsAfter, fIsBetween } from 'src/utils/format-time';
|
||||
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { _tours, _tourGuides, TOUR_SORT_OPTIONS, TOUR_SERVICE_OPTIONS } from 'src/_mock';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { EmptyContent } from 'src/components/empty-content';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
|
||||
import { TourList } from '../tour-list';
|
||||
import { TourSort } from '../tour-sort';
|
||||
import { TourSearch } from '../tour-search';
|
||||
import { TourFilters } from '../tour-filters';
|
||||
import { TourFiltersResult } from '../tour-filters-result';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function TourListView() {
|
||||
const openFilters = useBoolean();
|
||||
|
||||
const [sortBy, setSortBy] = useState('latest');
|
||||
|
||||
const filters = useSetState<ITourFilters>({
|
||||
destination: [],
|
||||
tourGuides: [],
|
||||
services: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
const { state: currentFilters } = filters;
|
||||
|
||||
const dateError = fIsAfter(currentFilters.startDate, currentFilters.endDate);
|
||||
|
||||
const dataFiltered = applyFilter({
|
||||
inputData: _tours,
|
||||
filters: currentFilters,
|
||||
sortBy,
|
||||
dateError,
|
||||
});
|
||||
|
||||
const canReset =
|
||||
currentFilters.destination.length > 0 ||
|
||||
currentFilters.tourGuides.length > 0 ||
|
||||
currentFilters.services.length > 0 ||
|
||||
(!!currentFilters.startDate && !!currentFilters.endDate);
|
||||
|
||||
const notFound = !dataFiltered.length && canReset;
|
||||
|
||||
const handleSortBy = useCallback((newValue: string) => {
|
||||
setSortBy(newValue);
|
||||
}, []);
|
||||
|
||||
const renderFilters = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<TourSearch redirectPath={(id: string) => paths.dashboard.tour.details(id)} />
|
||||
|
||||
<Box sx={{ gap: 1, flexShrink: 0, display: 'flex' }}>
|
||||
<TourFilters
|
||||
filters={filters}
|
||||
canReset={canReset}
|
||||
dateError={dateError}
|
||||
open={openFilters.value}
|
||||
onOpen={openFilters.onTrue}
|
||||
onClose={openFilters.onFalse}
|
||||
options={{
|
||||
tourGuides: _tourGuides,
|
||||
services: TOUR_SERVICE_OPTIONS.map((option) => option.label),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TourSort sort={sortBy} onSort={handleSortBy} sortOptions={TOUR_SORT_OPTIONS} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderResults = () => (
|
||||
<TourFiltersResult filters={filters} totalResults={dataFiltered.length} />
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="List"
|
||||
links={[
|
||||
{ name: 'Dashboard', href: paths.dashboard.root },
|
||||
{ name: 'Tour', href: paths.dashboard.tour.root },
|
||||
{ name: 'List' },
|
||||
]}
|
||||
action={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.tour.new}
|
||||
variant="contained"
|
||||
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||
>
|
||||
New Tour
|
||||
</Button>
|
||||
}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<Stack spacing={2.5} sx={{ mb: { xs: 3, md: 5 } }}>
|
||||
{renderFilters()}
|
||||
{canReset && renderResults()}
|
||||
</Stack>
|
||||
|
||||
{notFound && <EmptyContent filled sx={{ py: 10 }} />}
|
||||
|
||||
<TourList tours={dataFiltered} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ApplyFilterProps = {
|
||||
sortBy: string;
|
||||
dateError: boolean;
|
||||
filters: ITourFilters;
|
||||
inputData: ITourItem[];
|
||||
};
|
||||
|
||||
function applyFilter({ inputData, filters, sortBy, dateError }: ApplyFilterProps) {
|
||||
const { services, destination, startDate, endDate, tourGuides } = filters;
|
||||
|
||||
const tourGuideIds = tourGuides.map((tourGuide) => tourGuide.id);
|
||||
|
||||
// Sort by
|
||||
if (sortBy === 'latest') {
|
||||
inputData = orderBy(inputData, ['createdAt'], ['desc']);
|
||||
}
|
||||
|
||||
if (sortBy === 'oldest') {
|
||||
inputData = orderBy(inputData, ['createdAt'], ['asc']);
|
||||
}
|
||||
|
||||
if (sortBy === 'popular') {
|
||||
inputData = orderBy(inputData, ['totalViews'], ['desc']);
|
||||
}
|
||||
|
||||
// Filters
|
||||
if (destination.length) {
|
||||
inputData = inputData.filter((tour) => destination.includes(tour.destination));
|
||||
}
|
||||
|
||||
if (tourGuideIds.length) {
|
||||
inputData = inputData.filter((tour) =>
|
||||
tour.tourGuides.some((filterItem) => tourGuideIds.includes(filterItem.id))
|
||||
);
|
||||
}
|
||||
|
||||
if (services.length) {
|
||||
inputData = inputData.filter((tour) => tour.services.some((item) => services.includes(item)));
|
||||
}
|
||||
|
||||
if (!dateError) {
|
||||
if (startDate && endDate) {
|
||||
inputData = inputData.filter((tour) =>
|
||||
fIsBetween(startDate, tour.available.startDate, tour.available.endDate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return inputData;
|
||||
}
|
Reference in New Issue
Block a user