39443-vm/frontend/src/components/Reservations/TableReservations.tsx
2026-04-03 22:03:23 +00:00

609 lines
24 KiB
TypeScript

import React, { useEffect, useState, useMemo } from 'react'
import { createPortal } from 'react-dom';
import { ToastContainer, toast } from 'react-toastify';
import BaseButton from '../BaseButton'
import CardBoxModal from '../CardBoxModal'
import CardBox from "../CardBox";
import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/reservations/reservationsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import { Field, Form, Formik } from "formik";
import {
DataGrid,
GridColDef,
} from '@mui/x-data-grid';
import {loadColumns} from "./configureReservationsCols";
import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter'
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
import {dataGridStyles} from "../../styles";
import BigCalendar from "../BigCalendar";
import { SlotInfo } from 'react-big-calendar';
const perPage = 25
const compactColumnVisibilityModel = {
tenant: false,
organization: false,
booking_request: false,
unit_type: false,
actual_check_in_at: false,
actual_check_out_at: false,
early_check_in: false,
late_check_out: false,
monthly_rate: false,
currency: false,
internal_notes: false,
external_notes: false,
guests: false,
service_requests: false,
invoices: false,
documents: false,
comments: false,
}
const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGrid }) => {
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
const dispatch = useAppDispatch();
const router = useRouter();
const pagesList = [];
const [id, setId] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState<GridColDef[]>([]);
const [selectedRows, setSelectedRows] = useState([]);
const [sortModel, setSortModel] = useState([
{
field: '',
sort: 'desc',
},
]);
const { reservations, loading, count, notify: reservationsNotify, refetch } = useAppSelector((state) => state.reservations)
const { currentUser } = useAppSelector((state) => state.auth);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);
const numPages = Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage);
for (let i = 0; i < numPages; i++) {
pagesList.push(i);
}
const loadData = async (page = currentPage, request = filterRequest) => {
if (page !== currentPage) setCurrentPage(page);
if (request !== filterRequest) setFilterRequest(request);
const { sort, field } = sortModel[0];
const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`;
dispatch(fetch({ limit: perPage, page, query }));
};
useEffect(() => {
if (reservationsNotify.showNotification) {
notify(reservationsNotify.typeNotification, reservationsNotify.textNotification);
}
}, [reservationsNotify.showNotification]);
useEffect(() => {
if (!currentUser) return;
loadData();
}, [sortModel, currentUser]);
useEffect(() => {
if (refetch) {
loadData(0);
dispatch(setRefetch(false));
}
}, [refetch, dispatch]);
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
useEffect(() => {
if (validFilterItems !== filterItems) {
setFilterItems(validFilterItems);
}
}, [filterItems, setFilterItems, validFilterItems]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
const handleModalAction = () => {
setIsModalInfoActive(false)
setIsModalTrashActive(false)
}
const handleCreateEventAction = ({ start, end }: SlotInfo) => {
router.push(
`/reservations/reservations-new?dateRangeStart=${start.toISOString()}&dateRangeEnd=${end.toISOString()}`,
);
};
const handleDeleteModalAction = (id: string) => {
setId(id)
setIsModalTrashActive(true)
}
const handleDeleteAction = async () => {
if (id) {
await dispatch(deleteItem(id));
await loadData(0);
setIsModalTrashActive(false);
}
};
const reservationCalendarOverview = useMemo(() => {
const today = new Date();
const nextSevenDays = new Date(today);
nextSevenDays.setDate(nextSevenDays.getDate() + 7);
const totals = reservations.reduce(
(accumulator, item) => {
const checkIn = item?.check_in_at ? new Date(item.check_in_at) : null;
const checkOut = item?.check_out_at ? new Date(item.check_out_at) : null;
const status = item?.status || 'quoted';
accumulator.total += 1;
accumulator[status] = (accumulator[status] || 0) + 1;
if (checkIn && checkIn >= today && checkIn <= nextSevenDays) {
accumulator.upcomingArrivals += 1;
}
if (checkOut && checkOut >= today && checkOut <= nextSevenDays) {
accumulator.upcomingDepartures += 1;
}
return accumulator;
},
{
total: 0,
confirmed: 0,
checked_in: 0,
upcomingArrivals: 0,
upcomingDepartures: 0,
},
);
return [
{
label: 'Visible stays',
value: totals.total,
hint: 'Loaded in this calendar range',
},
{
label: 'Confirmed',
value: totals.confirmed,
hint: 'Booked and upcoming',
},
{
label: 'In house',
value: totals.checked_in,
hint: 'Currently checked in',
},
{
label: 'Departures soon',
value: totals.upcomingDepartures,
hint: 'Next 7 days',
},
];
}, [reservations]);
const generateFilterRequests = useMemo(() => {
let request = '&';
validFilterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) {
request += `${item.fields.selectedField}Range=${from}&`;
}
if (to) {
request += `${item.fields.selectedField}Range=${to}&`;
}
} else {
const value = item.fields.filterValue;
if (value) {
request += `${item.fields.selectedField}=${value}&`;
}
}
});
return request;
}, [filters, validFilterItems]);
const deleteFilter = (value) => {
const newItems = validFilterItems.filter((item) => item.id !== value);
if (newItems.length) {
setFilterItems(newItems);
} else {
loadData(0, '');
setFilterItems(newItems);
}
};
const handleSubmit = () => {
loadData(0, generateFilterRequests);
};
const handleChange = (id) => (e) => {
const value = e.target.value;
const name = e.target.name;
setFilterItems(
validFilterItems.map((item) => {
if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } };
return { id, fields: { ...item.fields, [name]: value } }
}),
);
};
const handleReset = () => {
setFilterItems([]);
loadData(0, '');
};
const onPageChange = (page: number) => {
loadData(page);
setCurrentPage(page);
};
useEffect(() => {
if (!currentUser) return;
loadColumns(
handleDeleteModalAction,
`reservations`,
currentUser,
).then((newCols) => setColumns(newCols));
}, [currentUser]);
const handleTableSubmit = async (id: string, data) => {
if (!_.isEmpty(data)) {
await dispatch(update({ id, data }))
.unwrap()
.then((res) => res)
.catch((err) => {
throw new Error(err);
});
}
};
const onDeleteRows = async (selectedRows) => {
await dispatch(deleteItemsByIds(selectedRows));
await loadData(0);
};
const controlClasses =
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
'dark:bg-slate-800/80 my-1';
const dataGrid = (
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={56}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => `datagrid--row`}
rows={reservations ?? []}
columns={columns}
initialState={{
pagination: {
paginationModel: {
pageSize: 25,
},
},
columns: {
columnVisibilityModel: compactColumnVisibilityModel,
},
}}
disableRowSelectionOnClick
onProcessRowUpdateError={(params) => {
console.log('Error', params);
}}
processRowUpdate={async (newRow, oldRow) => {
const data = dataFormatter.dataGridEditFormatter(newRow);
try {
await handleTableSubmit(newRow.id, data);
return newRow;
} catch {
return oldRow;
}
}}
sortingMode={'server'}
checkboxSelection
onRowSelectionModelChange={(ids) => {
setSelectedRows(ids)
}}
onSortModelChange={(params) => {
params.length
? setSortModel(params)
: setSortModel([{ field: '', sort: 'desc' }]);
}}
rowCount={count}
pageSizeOptions={[25]}
paginationMode={'server'}
loading={loading}
onPaginationModelChange={(params) => {
onPageChange(params.page);
}}
/>
</div>
)
return (
<>
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
<CardBox className='mb-6 border border-white/10 shadow-none'>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
<>
{validFilterItems && validFilterItems.map((filterItem) => {
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
return (
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,14rem)]">
<div className="flex min-w-0 flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
<Field
className={controlClasses}
name='selectedField'
id='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleChange(filterItem.id)}
>
{filters.map((selectOption) => (
<option
key={selectOption.title}
value={`${selectOption.title}`}
>
{selectOption.label}
</option>
))}
</Field>
</div>
{filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? (
<div className="flex min-w-0 flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
Value
</div>
<Field
className={controlClasses}
name="filterValue"
id='filterValue'
component="select"
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
>
<option value="">Select Value</option>
{filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.number ? (
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<div className="flex min-w-0 flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className="flex flex-col w-full">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : filters.find(
(filter) =>
filter.title ===
filterItem?.fields?.selectedField
)?.date ? (
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
<div className='flex flex-col w-full mr-3'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
From
</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
type='datetime-local'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
type='datetime-local'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : (
<div className="flex min-w-0 flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Contained'
id='filterValue'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
)}
<div className="flex flex-col gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
<BaseButton
className="my-1 w-full md:w-auto"
type='reset'
color='whiteDark'
outline
label='Delete'
onClick={() => {
deleteFilter(filterItem.id)
}}
/>
{incompleteHint ? (
<p className='rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100'>
{incompleteHint}
</p>
) : null}
</div>
</div>
)
})}
<div className="flex flex-wrap items-center gap-2">
<BaseButton
className="my-1 mr-0"
type='submit' color='info'
label='Apply'
onClick={handleSubmit}
/>
<BaseButton
className="my-1"
type='reset' color='whiteDark' outline
label='Cancel'
onClick={handleReset}
/>
</div>
</>
</Form>
</Formik>
</CardBox> : null
}
<CardBoxModal
title="Please confirm"
buttonColor="info"
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={isModalTrashActive}
onConfirm={handleDeleteAction}
onCancel={handleModalAction}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
{!showGrid && (
<>
<div className='mb-6 grid gap-3 md:grid-cols-4'>
{reservationCalendarOverview.map((item) => (
<CardBox key={item.label} className='rounded-2xl border border-gray-200/80 shadow-none dark:border-dark-700'>
<div className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
{item.label}
</p>
<p className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>
{item.value}
</p>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{item.hint}</p>
</div>
</CardBox>
))}
</div>
<BigCalendar
events={reservations}
showField={'reservation_code'}
start-data-key={'check_in_at'}
end-data-key={'check_out_at'}
handleDeleteAction={handleDeleteModalAction}
pathEdit={`/reservations/reservations-edit/?id=`}
pathView={`/reservations/reservations-view/?id=`}
handleCreateEventAction={handleCreateEventAction}
onDateRangeChange={(range) => {
loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`);
}}
isLoading={loading}
emptyTitle='No reservations in this range'
emptyDescription='Adjust the calendar window or add a reservation to start filling the schedule.'
entityName={'reservations'}
/>
</>
)}
{showGrid && dataGrid}
{selectedRows.length > 0 &&
createPortal(
<BaseButton
className='me-4'
color='danger'
label={`Delete ${selectedRows.length === 1 ? 'Row' : 'Rows'}`}
onClick={() => onDeleteRows(selectedRows)}
/>,
document.getElementById('delete-rows-button'),
)}
<ToastContainer />
</>
)
}
export default TableSampleReservations