init commit,

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

View File

@@ -0,0 +1,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>
);
}

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

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

View 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>
);
}

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

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

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

View 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>
);
}

View 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 };
}

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

View 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';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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;
}