From e6b147781f135aaad41a09cd4768133bdfbec89b Mon Sep 17 00:00:00 2001 From: Tim Tian Date: Wed, 20 Aug 2025 19:27:51 +0930 Subject: [PATCH 1/3] Fix booking search clear functionality and add sorting by creation time, also translate Chinese comments to English --- .../components/TaskManager/BookingManager.tsx | 25 +++- .../components/TaskManager/BookingModal.tsx | 120 +++++++++++---- .../TaskManager/EditBookingModal.tsx | 106 ++++++++++--- src/app/admin/booking/page.tsx | 1 + .../components/CustomFormModal.tsx | 139 +++++++++++++++--- .../components/DeleteConfirmModal.tsx | 11 ++ .../components/EditServiceModal.tsx | 129 ++++++++++++++-- .../serviceManagementApi.ts | 109 +++++++++++++- 8 files changed, 552 insertions(+), 88 deletions(-) diff --git a/src/app/admin/booking/components/TaskManager/BookingManager.tsx b/src/app/admin/booking/components/TaskManager/BookingManager.tsx index 2191b85..7729d4b 100644 --- a/src/app/admin/booking/components/TaskManager/BookingManager.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingManager.tsx @@ -56,6 +56,7 @@ export function Content({ search, filterAnchor, onFilterClose, + onClearSearch, onCreateBooking, isCreateBookingModalOpen, onCloseCreateBookingModal, @@ -63,6 +64,7 @@ export function Content({ search: string; filterAnchor: HTMLElement | null; onFilterClose: () => void; + onClearSearch: () => void; onCreateBooking: () => void; isCreateBookingModalOpen: boolean; onCloseCreateBookingModal: () => void; @@ -230,6 +232,7 @@ export function Content({ const handleSearch = (filters?: Record | string) => { if (typeof filters === 'string') { // Handle search clear + onClearSearch(); return; } @@ -314,11 +317,24 @@ export function Content({ return matchesSearch && matchesStatus && matchesCreator && matchesDateRange; }); + // Sort services by creation time (newest first) + const sortedServices = [...filteredServices].sort((a, b) => { + // Sort by dateTime (booking time) - newest first + if (a.dateTime && b.dateTime) { + return new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime(); + } + // If no dateTime, sort by _id (newest first, assuming _id contains timestamp) + if (a._id && b._id) { + return b._id.localeCompare(a._id); + } + return 0; + }); + // Pagination - const totalPages = Math.ceil(filteredServices.length / TASKS_PER_PAGE); + const totalPages = Math.ceil(sortedServices.length / TASKS_PER_PAGE); const startIndex = (currentPage - 1) * TASKS_PER_PAGE; const endIndex = startIndex + TASKS_PER_PAGE; - const paginatedServices = filteredServices.slice(startIndex, endIndex); + const paginatedServices = sortedServices.slice(startIndex, endIndex); return ( @@ -432,6 +448,9 @@ export default function BookingManager({ onFilterClose = () => { // Default empty function }, + onClearSearch = () => { + // Default empty function + }, isCreateBookingModalOpen = false, onCloseCreateBookingModal = () => { // Default empty function @@ -440,6 +459,7 @@ export default function BookingManager({ search?: string; filterAnchor?: HTMLElement | null; onFilterClose?: () => void; + onClearSearch?: () => void; isCreateBookingModalOpen?: boolean; onCloseCreateBookingModal?: () => void; }) { @@ -453,6 +473,7 @@ export default function BookingManager({ search={search} filterAnchor={filterAnchor} onFilterClose={handleFilterClose} + onClearSearch={onClearSearch} onCreateBooking={() => { // This will be handled by the Content component }} diff --git a/src/app/admin/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index 383c017..f7edb71 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -240,12 +240,23 @@ const BookingModal: React.FC = ({ }) => { const theme = useTheme(); useMediaQuery(theme.breakpoints.down('sm')); + + // Get current date and time in local format + const getCurrentDateTimeLocal = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + const [name, setName] = useState(''); const [selectedServiceId, setSelectedServiceId] = useState(''); // New: Save selected service _id const [status, setStatus] = useState(''); const [datetime, setDatetime] = useState(() => { - const now = new Date(); - return now.toISOString().slice(0, 16); + return getCurrentDateTimeLocal(); }); const [description, setDescription] = useState(''); const [client, setClient] = useState({ @@ -256,18 +267,51 @@ const BookingModal: React.FC = ({ const [createServiceBooking] = useCreateServiceBookingMutation(); const user = useAppSelector(state => state.auth.user); - // Get current date and time in local format for min attribute - const getCurrentDateTimeLocal = () => { - const now = new Date(); - return now.toISOString().slice(0, 16); - }; - // Validate if selected datetime is in the past const isDateTimeInPast = (dateTimeString: string) => { if (!dateTimeString) return false; - const selectedDate = new Date(dateTimeString); - const now = new Date(); - return selectedDate < now; + try { + const selectedDate = new Date(dateTimeString); + const now = new Date(); + if (isNaN(selectedDate.getTime())) return false; + + // Check if date is valid + if (selectedDate > now) return false; + + // Use more lenient comparison: compare to minute level, ignore seconds and milliseconds + const selectedYear = selectedDate.getFullYear(); + const selectedMonth = selectedDate.getMonth(); + const selectedDay = selectedDate.getDate(); + const selectedHour = selectedDate.getHours(); + const selectedMinute = selectedDate.getMinutes(); + + const nowYear = now.getFullYear(); + const nowMonth = now.getMonth(); + const nowDay = now.getDate(); + const nowHour = now.getHours(); + const nowMinute = now.getMinutes(); + + // If year, month, day, hour, minute are all the same, it's considered current time + if ( + selectedYear === nowYear && + selectedMonth === nowMonth && + selectedDay === nowDay && + selectedHour === nowHour && + selectedMinute === nowMinute + ) { + return false; // Current time, not past time + } + + // Compare timestamps (to minute level) + const selectedMinutes = selectedDate.getTime() / (1000 * 60); + const nowMinutes = now.getTime() / (1000 * 60); + + // Reduce tolerance to 1 minute + return selectedMinutes < nowMinutes - 1; + } catch (error) { + console.error('Error in isDateTimeInPast:', error); + return false; + } }; const userName = @@ -291,10 +335,10 @@ const BookingModal: React.FC = ({ const isValid = name && - selectedServiceId && // Modified: Check selectedServiceId status && datetime && - !isDateTimeInPast(datetime) && + // If status is Done, time must be past; if status is not Done, do not allow past time + (status !== 'Done' || isDateTimeInPast(datetime)) && client.name && client.phoneNumber && client.address; @@ -346,23 +390,34 @@ const BookingModal: React.FC = ({ } } - const handleCreate = async () => { + const handleCreate = async (): Promise => { try { if (!user) { throw new Error('User is missing, please login again.'); } + + if (!selectedServiceId) { + alert('Please select a service'); + return; + } + const bookingStatus = mapStatusToBookingStatus(status); const bookingTime = toBackendDateString(datetime); if (!bookingTime) { alert('Please select a valid date and time'); return; } - if (isDateTimeInPast(datetime)) { - alert('You cannot book a service for a past date and time.'); + + if (isDateTimeInPast(datetime) && status !== 'Done') { + alert( + 'You cannot book a service for a past date and time unless the status is Done.', + ); return; } + // Remove popup alerts, use form validation to control button state instead + await createServiceBooking({ - serviceId: selectedServiceId, // Modified: Use selected service _id + serviceId: selectedServiceId, client: { name: client.name, phoneNumber: client.phoneNumber, @@ -374,11 +429,13 @@ const BookingModal: React.FC = ({ note: description, userId: user?._id, }).unwrap(); + const statusMap: Record = { Done: 'Done', Cancelled: 'Cancelled', Confirmed: 'Confirmed', }; + onCreate({ name, price: 0, @@ -392,9 +449,10 @@ const BookingModal: React.FC = ({ status: statusMap[bookingStatus], description, }); + onClose(); - } catch { - // Error handling removed for lint compliance + } catch (error) { + console.error('Failed to create booking:', error); } }; @@ -508,7 +566,15 @@ const BookingModal: React.FC = ({ }} > {['Done', 'Cancelled', 'Confirmed'].map(option => ( - + {option} ))} @@ -526,12 +592,16 @@ const BookingModal: React.FC = ({ setDatetime(e.target.value) } InputLabelProps={{ shrink: true }} - inputProps={{ min: getCurrentDateTimeLocal() }} - error={isDateTimeInPast(datetime)} + inputProps={{ + min: status === 'Done' ? undefined : getCurrentDateTimeLocal(), + }} + error={isDateTimeInPast(datetime) && status !== 'Done'} helperText={ - isDateTimeInPast(datetime) - ? 'Date and time cannot be in the past' - : '' + isDateTimeInPast(datetime) && status !== 'Done' + ? 'Cannot create booking for past time' + : status === 'Done' && isDateTimeInPast(datetime) + ? 'Recording completion time (past time allowed)' + : '' } /> diff --git a/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx b/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx index 8ba47ef..ee42507 100644 --- a/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx @@ -265,22 +265,51 @@ interface Props { onDelete: (bookingId: string) => void; } -// Utility function: Convert ISO string to datetime-local format (local time) -function formatForDateTimeLocal(isoString: string): string { - if (!isoString) return ''; +// Format ISO string for datetime-local input +const formatForDateTimeLocal = (isoString: string) => { + if (!isoString) { + // If no time, use current device time + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + try { + // Create Date object, this automatically converts ISO string to local time const date = new Date(isoString); - if (isNaN(date.getTime())) return ''; + if (isNaN(date.getTime())) { + // If parsing fails, also use current device time + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + + // Get local time components const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; - } catch { - return ''; + } catch (error) { + // If exception occurs, also use current device time + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; } -} +}; const EditBookingModal: React.FC = ({ service, @@ -305,18 +334,38 @@ const EditBookingModal: React.FC = ({ address: service.client?.address ?? '', }); - // Get current date and time in local format for min attribute + // Get current date and time in local format const getCurrentDateTimeLocal = () => { const now = new Date(); - return now.toISOString().slice(0, 16); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; }; // Validate if selected datetime is in the past const isDateTimeInPast = (dateTimeString: string) => { if (!dateTimeString) return false; - const selectedDate = new Date(dateTimeString); - const now = new Date(); - return selectedDate < now; + try { + const selectedDate = new Date(dateTimeString); + const now = new Date(); + if (isNaN(selectedDate.getTime())) return false; + + // Check if date is valid + if (selectedDate > now) return false; + + // Use more lenient comparison: compare to minute level, ignore seconds and milliseconds + const selectedMinutes = selectedDate.getTime() / (1000 * 60); + const nowMinutes = now.getTime() / (1000 * 60); + + // Reduce tolerance to 1 minute + return selectedMinutes < nowMinutes - 1; + } catch { + console.error('Error in isDateTimeInPast'); + return false; + } }; // Get service-management services list @@ -331,17 +380,21 @@ const EditBookingModal: React.FC = ({ name && status && dateTime && - !isDateTimeInPast(dateTime) && + // If status is Done, time must be past; if status is not Done, do not allow past time + (status !== 'Done' || isDateTimeInPast(dateTime)) && client.name && client.phoneNumber && client.address; const handleSave = () => { - // Validate that the selected date is not in the past - if (isDateTimeInPast(dateTime)) { - alert('You cannot save a booking for a past date and time.'); + // Validate that the selected date is not in the past (unless status is Done) + if (isDateTimeInPast(dateTime) && status !== 'Done') { + alert( + 'You cannot save a booking for a past date and time unless the status is Done.', + ); return; } + // Remove popup alerts, use form validation to control button state instead // Convert to ISO string when saving let isoDateTime = dateTime; @@ -494,7 +547,12 @@ const EditBookingModal: React.FC = ({ } displayEmpty > - Done + + Done + Cancelled Confirmed @@ -511,12 +569,16 @@ const EditBookingModal: React.FC = ({ setDateTime(e.target.value) } InputLabelProps={{ shrink: true }} - inputProps={{ min: getCurrentDateTimeLocal() }} - error={isDateTimeInPast(dateTime)} + inputProps={{ + min: status === 'Done' ? undefined : getCurrentDateTimeLocal(), + }} + error={isDateTimeInPast(dateTime) && status !== 'Done'} helperText={ - isDateTimeInPast(dateTime) - ? 'Date and time cannot be in the past' - : '' + isDateTimeInPast(dateTime) && status !== 'Done' + ? 'Cannot save booking for past time' + : status === 'Done' && isDateTimeInPast(dateTime) + ? 'Recording completion time (past time allowed)' + : '' } /> diff --git a/src/app/admin/booking/page.tsx b/src/app/admin/booking/page.tsx index 70a0ab9..ca9c5fd 100644 --- a/src/app/admin/booking/page.tsx +++ b/src/app/admin/booking/page.tsx @@ -186,6 +186,7 @@ export default function BookingPage() { search={search} filterAnchor={filterAnchor} onFilterClose={handleFilterClose} + onClearSearch={handleClearSearch} isCreateBookingModalOpen={isCreateBookingModalOpen} onCloseCreateBookingModal={handleCloseCreateBookingModal} /> diff --git a/src/app/admin/service-management/components/CustomFormModal.tsx b/src/app/admin/service-management/components/CustomFormModal.tsx index f2f7280..80dae9e 100644 --- a/src/app/admin/service-management/components/CustomFormModal.tsx +++ b/src/app/admin/service-management/components/CustomFormModal.tsx @@ -22,7 +22,7 @@ import { useMediaQuery, } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import theme from '@/theme'; @@ -158,6 +158,13 @@ const ActionButton = styled(IconButton)(() => ({ '&:hover': { backgroundColor: '#f5f5f5', }, + '&.Mui-disabled': { + color: '#ccc', + cursor: 'not-allowed', + '&:hover': { + backgroundColor: 'transparent', + }, + }, })); const RequiredToggle = styled(Box)(() => ({ @@ -211,6 +218,7 @@ interface CustomFormModalProps { open: boolean; onClose: () => void; onSave?: (fields: FormField[]) => void; + initialFields?: FormField[]; } const fieldTypes = [ @@ -239,19 +247,37 @@ export default function CustomFormModal({ open, onClose, onSave, + initialFields, }: CustomFormModalProps) { - const [fields, setFields] = useState([ - { - id: '1', - type: 'short-answer', - label: '', - required: false, - options: [], - }, - ]); + const [fields, setFields] = useState([]); useMediaQuery(theme.breakpoints.down('sm')); + // 当modal打开或initialFields变化时,更新fields + useEffect(() => { + if (open) { + if (initialFields && initialFields.length > 0) { + // 深拷贝初始字段,避免引用问题 + const clonedFields = initialFields.map(field => ({ + ...field, + options: field.options ? [...field.options] : [], + })); + setFields(clonedFields); + } else { + // 如果没有初始字段,设置默认字段 + setFields([ + { + id: '1', + type: 'short-answer', + label: '', + required: false, + options: [], + }, + ]); + } + } + }, [open, initialFields]); + const handleFieldTypeChange = (fieldId: string, type: string) => { setFields(prev => prev.map(field => (field.id === fieldId ? { ...field, type } : field)), @@ -280,6 +306,7 @@ export default function CustomFormModal({ type: fieldToDuplicate.type, label: fieldToDuplicate.label, required: fieldToDuplicate.required, + options: fieldToDuplicate.options ? [...fieldToDuplicate.options] : [], }; setFields(prev => [...prev, newField]); } @@ -287,7 +314,33 @@ export default function CustomFormModal({ const handleDeleteField = (fieldId: string) => { if (fields.length > 1) { + // 多个字段时,直接删除 setFields(prev => prev.filter(field => field.id !== fieldId)); + } else { + // 只有一个字段时,检查 label 是否为空 + const currentField = fields.find(field => field.id === fieldId); + if (currentField && currentField.label.trim() === '') { + // label 为空时,不允许删除 + console.log( + 'Cannot delete field with empty label. Please fill in the label first.', + ); + return; + } else { + // label 不为空时,清空内容但保留字段 + setFields(prev => + prev.map(field => + field.id === fieldId + ? { + ...field, + label: '', + required: false, + options: [], + type: 'short-answer', // 重置为默认类型 + } + : field, + ), + ); + } } }; @@ -344,21 +397,42 @@ export default function CustomFormModal({ const handleCreate = () => { if (onSave) { - onSave(fields); + // 过滤掉空的字段(label为空的字段) + const validFields = fields.filter(field => field.label.trim() !== ''); + + // 如果所有字段都是空的,至少保留一个默认字段 + if (validFields.length === 0) { + validFields.push({ + id: Date.now().toString(), + type: 'short-answer', + label: '', + required: false, + options: [], + }); + } + + onSave(validFields); } onClose(); }; const handleClose = () => { - setFields([ - { - id: '1', - type: 'short-answer', - label: '', - required: false, - options: [], - }, - ]); + // 如果没有任何字段或者所有字段都是空的,则重置为默认字段 + if ( + fields.length === 0 || + fields.every(field => field.label.trim() === '') + ) { + setFields([ + { + id: '1', + type: 'short-answer', + label: '', + required: false, + options: [], + }, + ]); + } + // 否则保持当前字段状态,让用户下次打开时看到之前的内容 onClose(); }; @@ -569,11 +643,32 @@ export default function CustomFormModal({ handleDeleteField(field.id)} - title="Delete field" - disabled={fields.length === 1} + title={ + fields.length === 1 && field.label.trim() === '' + ? 'Cannot delete empty field. Please fill in the label first.' + : fields.length === 1 + ? 'Clear field content' + : 'Delete field' + } + disabled={fields.length === 1 && field.label.trim() === ''} > + {fields.length === 1 && ( + + {field.label.trim() === '' + ? 'Fill label to clear' + : 'Click to clear'} + + )} diff --git a/src/app/admin/service-management/components/DeleteConfirmModal.tsx b/src/app/admin/service-management/components/DeleteConfirmModal.tsx index 8656d5a..a0f1883 100644 --- a/src/app/admin/service-management/components/DeleteConfirmModal.tsx +++ b/src/app/admin/service-management/components/DeleteConfirmModal.tsx @@ -157,6 +157,17 @@ export default function DeleteConfirmModal({ Are you sure you want to delete "{service?.name}"? This action cannot be undone. + + Note: Created bookings will not be affected. + diff --git a/src/app/admin/service-management/components/EditServiceModal.tsx b/src/app/admin/service-management/components/EditServiceModal.tsx index b134e08..b94624e 100644 --- a/src/app/admin/service-management/components/EditServiceModal.tsx +++ b/src/app/admin/service-management/components/EditServiceModal.tsx @@ -12,29 +12,25 @@ import { useMediaQuery, } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { CreateServiceManagementDto, + FormField, ServiceManagement, UpdateServiceManagementDto, } from '@/features/service-management/serviceManagementApi'; - -import CustomFormModal from './CustomFormModal'; - -interface FormField { - id: string; - type: string; - label: string; - required: boolean; -} import { useCreateServiceMutation, + useGetServiceFormFieldsQuery, + useSaveServiceFormFieldsMutation, useUpdateServiceMutation, } from '@/features/service-management/serviceManagementApi'; import { useAppSelector } from '@/redux/hooks'; import theme from '@/theme'; +import CustomFormModal from './CustomFormModal'; + const ModalContainer = styled(Box)(({ theme }) => ({ position: 'absolute', top: '50%', @@ -213,6 +209,7 @@ export default function EditServiceModal({ }); const [priceInput, setPriceInput] = useState('0'); const [isCustomFormModalOpen, setIsCustomFormModalOpen] = useState(false); + const [customFormFields, setCustomFormFields] = useState([]); useMediaQuery(theme.breakpoints.down('sm')); useMediaQuery(theme.breakpoints.down('xs')); @@ -220,6 +217,36 @@ export default function EditServiceModal({ const [createService, { isLoading: isCreating }] = useCreateServiceMutation(); const [updateService, { isLoading: isUpdating }] = useUpdateServiceMutation(); + const [saveServiceFormFields] = useSaveServiceFormFieldsMutation(); + + // 获取现有的表单字段 + const { data: existingFormFields = [] } = useGetServiceFormFieldsQuery( + { serviceId: service?._id ?? '' }, + { skip: !service?._id }, + ); + + // 转换后端字段格式到前端格式 + const convertedFields = useMemo(() => { + if (!existingFormFields || existingFormFields.length === 0) { + return []; + } + return existingFormFields.map(field => ({ + id: field._id ?? field.serviceId + '_' + Date.now(), + type: field.fieldType, + label: field.fieldName, + required: field.isRequired, + options: field.options ?? [], + })); + }, [existingFormFields]); + + // 当现有字段变化时,更新本地状态 + useEffect(() => { + const currentFieldsString = JSON.stringify(customFormFields); + const newFieldsString = JSON.stringify(convertedFields); + if (currentFieldsString !== newFieldsString) { + setCustomFormFields(convertedFields); + } + }, [convertedFields, customFormFields]); useEffect(() => { if (service) { @@ -266,6 +293,8 @@ export default function EditServiceModal({ const handleSubmit = async (): Promise => { try { + let currentServiceId = service?._id; + if (service) { // Update service const updateData: UpdateServiceManagementDto = { @@ -275,12 +304,43 @@ export default function EditServiceModal({ isAvailable: formData.isAvailable, }; await updateService({ id: service._id, data: updateData }).unwrap(); + currentServiceId = service._id; } else { // Create new service - await createService(formData).unwrap(); + const newService = await createService(formData).unwrap(); + currentServiceId = newService._id; + } + + // 保存表单字段 + if (currentServiceId && customFormFields.length > 0) { + // 过滤掉空的字段(label为空的字段) + const validFields = customFormFields.filter( + field => field.label.trim() !== '', + ); + + if (validFields.length > 0) { + const backendFields = validFields.map(field => ({ + serviceId: currentServiceId, + fieldName: field.label, + fieldType: field.type, + isRequired: field.required, + options: field.options ?? [], + })); + + await saveServiceFormFields({ + serviceId: currentServiceId, + fields: backendFields, + }).unwrap(); + } } + onClose(); - } catch { + } catch (error) { + console.error('Failed to save service:', error); + // 提供更详细的错误信息 + if (error && typeof error === 'object' && 'data' in error) { + console.error('Error details:', error.data); + } // Error handling can be added here } }; @@ -293,9 +353,43 @@ export default function EditServiceModal({ setIsCustomFormModalOpen(false); }; - const handleSaveCustomForm = (_fields: FormField[]) => { - // Handle saving custom form fields - // Here you can save the form fields to your backend + const handleSaveCustomForm = async (fields: FormField[]): Promise => { + try { + setCustomFormFields(fields); + + // 如果服务已存在,立即保存到后端 + if (service?._id) { + // 过滤掉空的字段(label为空的字段) + const validFields = fields.filter(field => field.label.trim() !== ''); + + if (validFields.length > 0) { + const backendFields = validFields.map(field => ({ + serviceId: service._id, + fieldName: field.label, + fieldType: field.type, + isRequired: field.required, + options: field.options ?? [], + })); + + await saveServiceFormFields({ + serviceId: service._id, + fields: backendFields, + }).unwrap(); + } + } else { + console.log( + 'No service ID, form fields will be saved when service is created', + ); + } + + setIsCustomFormModalOpen(false); + } catch (error) { + console.error('Failed to save custom form fields:', error); + // 提供更详细的错误信息 + if (error && typeof error === 'object' && 'data' in error) { + console.error('Error details:', error.data); + } + } }; const isLoading = isCreating || isUpdating; @@ -428,7 +522,10 @@ export default function EditServiceModal({ { + void handleSaveCustomForm(fields); + }} + initialFields={customFormFields} /> ); diff --git a/src/features/service-management/serviceManagementApi.ts b/src/features/service-management/serviceManagementApi.ts index 462d612..4aaa19a 100644 --- a/src/features/service-management/serviceManagementApi.ts +++ b/src/features/service-management/serviceManagementApi.ts @@ -44,10 +44,54 @@ export interface UpdateServiceManagementDto { isAvailable?: boolean; } +export interface FormField { + id: string; + type: string; + label: string; + required: boolean; + options?: string[]; +} + +// Service Form Field interfaces for backend communication +export interface ServiceFormField { + _id?: string; + serviceId: string; + fieldName: string; + fieldType: string; + isRequired: boolean; + options: string[]; +} + +export interface CreateServiceFormFieldDto { + serviceId: string; + fieldName: string; + fieldType: string; + isRequired: boolean; + options: string[]; +} + +export interface UpdateServiceFormFieldDto { + fieldName?: string; + fieldType?: string; + isRequired?: boolean; + options?: string[]; +} + +export interface BatchFormFieldDto { + fieldName: string; + fieldType: string; + isRequired: boolean; + options: string[]; +} + +export interface SaveBatchFormFieldsDto { + fields: BatchFormFieldDto[]; +} + export const serviceManagementApi = createApi({ reducerPath: 'serviceManagementApi', baseQuery: axiosBaseQuery(), - tagTypes: ['ServiceManagement'], + tagTypes: ['ServiceManagement', 'ServiceFormField'], endpoints: builder => ({ getServices: builder.query({ query: ({ userId }) => ({ @@ -109,6 +153,64 @@ export const serviceManagementApi = createApi({ }), invalidatesTags: ['ServiceManagement'], }), + + // Service Form Field endpoints + getServiceFormFields: builder.query< + ServiceFormField[], + { serviceId: string } + >({ + query: ({ serviceId }) => ({ + url: '/service-form-fields', + method: 'GET', + params: { serviceId }, + }), + providesTags: ['ServiceFormField'], + }), + + createServiceFormField: builder.mutation< + ServiceFormField, + CreateServiceFormFieldDto + >({ + query: data => ({ + url: '/service-form-fields', + method: 'POST', + data, + }), + invalidatesTags: ['ServiceFormField'], + }), + + updateServiceFormField: builder.mutation< + ServiceFormField, + { id: string; data: UpdateServiceFormFieldDto } + >({ + query: ({ id, data }) => ({ + url: `/service-form-fields/${id}`, + method: 'PATCH', + data, + }), + invalidatesTags: ['ServiceFormField'], + }), + + deleteServiceFormField: builder.mutation<{ message: string }, string>({ + query: id => ({ + url: `/service-form-fields/${id}`, + method: 'DELETE', + }), + invalidatesTags: ['ServiceFormField'], + }), + + // Batch operations for form fields + saveServiceFormFields: builder.mutation< + ServiceFormField[], + SaveBatchFormFieldsDto & { serviceId: string } + >({ + query: ({ serviceId, fields }) => ({ + url: `/service-form-fields/batch/${serviceId}`, + method: 'POST', + data: { fields }, + }), + invalidatesTags: ['ServiceFormField'], + }), }), }); @@ -119,4 +221,9 @@ export const { useCreateServiceMutation, useUpdateServiceMutation, useDeleteServiceMutation, + useGetServiceFormFieldsQuery, + useCreateServiceFormFieldMutation, + useUpdateServiceFormFieldMutation, + useDeleteServiceFormFieldMutation, + useSaveServiceFormFieldsMutation, } = serviceManagementApi; From 50e00c0149770e07809620e5496a8e649a5379db Mon Sep 17 00:00:00 2001 From: Tim Tian Date: Fri, 22 Aug 2025 23:22:05 +0930 Subject: [PATCH 2/3] Fix error --- .../components/TaskManager/BookingManager.tsx | 4 + .../components/TaskManager/BookingModal.tsx | 239 ++++++++++++++++-- .../components/TaskManager/ViewFormModal.tsx | 53 ++++ src/features/service/serviceApi.ts | 4 + 4 files changed, 285 insertions(+), 15 deletions(-) diff --git a/src/app/admin/booking/components/TaskManager/BookingManager.tsx b/src/app/admin/booking/components/TaskManager/BookingManager.tsx index 7729d4b..d77dfd8 100644 --- a/src/app/admin/booking/components/TaskManager/BookingManager.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingManager.tsx @@ -148,6 +148,10 @@ export function Content({ phoneNumber: booking.client?.phoneNumber ?? '', address: booking.client?.address ?? '', }, + // Add serviceFormValues to pass to ViewFormModal + serviceFormValues: booking.serviceFormValues || [], + // Add serviceId to help ViewFormModal find the correct service + serviceId: booking.serviceId, }; }); diff --git a/src/app/admin/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index f7edb71..2d35cf4 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -26,6 +26,7 @@ import type { TaskStatus } from '@/features/service/serviceApi'; import { type Service } from '@/features/service/serviceApi'; import { useCreateServiceBookingMutation } from '@/features/service/serviceBookingApi'; import type { ServiceManagement } from '@/features/service-management/serviceManagementApi'; +import { useGetServiceFormFieldsQuery } from '@/features/service-management/serviceManagementApi'; import { useAppSelector } from '@/redux/hooks'; interface Props { onClose: () => void; @@ -264,9 +265,18 @@ const BookingModal: React.FC = ({ phoneNumber: '', address: '', }); + const [customFormValues, setCustomFormValues] = useState< + Record + >({}); const [createServiceBooking] = useCreateServiceBookingMutation(); const user = useAppSelector(state => state.auth.user); + // Get custom form fields for the selected service + const { data: customFormFields = [] } = useGetServiceFormFieldsQuery( + { serviceId: selectedServiceId }, + { skip: !selectedServiceId }, + ); + // Validate if selected datetime is in the past const isDateTimeInPast = (dateTimeString: string) => { if (!dateTimeString) return false; @@ -337,11 +347,20 @@ const BookingModal: React.FC = ({ name && status && datetime && - // If status is Done, time must be past; if status is not Done, do not allow past time - (status !== 'Done' || isDateTimeInPast(datetime)) && + // If status is Done or Cancelled, time must be past; if status is not Done/Cancelled, do not allow past time + (status === 'Done' || + status === 'Cancelled' || + !isDateTimeInPast(datetime)) && client.name && client.phoneNumber && - client.address; + client.address && + // Check if all required custom form fields are filled + customFormFields.every( + field => + !field.isRequired || + (customFormValues[field._id!] && + customFormValues[field._id!].trim() !== ''), + ); // New: Find corresponding service _id based on selected service name const handleServiceNameChange = (serviceName: string) => { setName(serviceName); @@ -349,6 +368,16 @@ const BookingModal: React.FC = ({ s => s.name === serviceName, ); setSelectedServiceId(selectedService?._id ?? ''); + // Reset custom form values when service changes + setCustomFormValues({}); + }; + + // Handle custom form field value changes + const handleCustomFormFieldChange = (fieldId: string, value: string) => { + setCustomFormValues(prev => ({ + ...prev, + [fieldId]: value, + })); }; // Utility function: Map frontend status to backend booking status @@ -408,13 +437,32 @@ const BookingModal: React.FC = ({ return; } - if (isDateTimeInPast(datetime) && status !== 'Done') { + if ( + isDateTimeInPast(datetime) && + status !== 'Done' && + status !== 'Cancelled' + ) { alert( - 'You cannot book a service for a past date and time unless the status is Done.', + 'You cannot book a service for a past date and time unless the status is Done or Cancelled.', ); return; } - // Remove popup alerts, use form validation to control button state instead + + // Build serviceFormValues with custom form fields + const formValues = [ + // Include the service name as a basic field + { serviceFieldId: 'service_name', answer: name }, + ]; + + // Add custom form field values + customFormFields.forEach(field => { + if (customFormValues[field._id!]) { + formValues.push({ + serviceFieldId: field._id!, + answer: customFormValues[field._id!], + }); + } + }); await createServiceBooking({ serviceId: selectedServiceId, @@ -423,7 +471,7 @@ const BookingModal: React.FC = ({ phoneNumber: client.phoneNumber, address: client.address, }, - serviceFormValues: [{ serviceFieldId: 'dummy', answer: name }], + serviceFormValues: formValues, bookingTime, status: bookingStatus, note: description, @@ -472,9 +520,8 @@ const BookingModal: React.FC = ({ ) => - handleServiceNameChange(e.target.value as string) // Modified: Use new handler function + onChange={(e: SelectChangeEvent) => + handleServiceNameChange(e.target.value as string) } displayEmpty renderValue={selected => { @@ -501,6 +548,158 @@ const BookingModal: React.FC = ({ + {/* Custom Form Fields */} + {customFormFields.length > 0 && ( + <> + {customFormFields.map(field => ( + + + {field.fieldName} + {field.isRequired && ( + * + )} + + {field.fieldType === 'short-answer' && ( + ) => + handleCustomFormFieldChange(field._id!, e.target.value) + } + variant="outlined" + required={field.isRequired} + /> + )} + {field.fieldType === 'paragraph' && ( + ) => + handleCustomFormFieldChange(field._id!, e.target.value) + } + required={field.isRequired} + /> + )} + {field.fieldType === 'dropdown-list' && field.options && ( + ) => + handleCustomFormFieldChange( + field._id!, + e.target.value as string, + ) + } + displayEmpty + required={field.isRequired} + > + + Please Select + + {field.options.map(option => ( + + {option} + + ))} + + )} + {field.fieldType === 'single-choice' && field.options && ( + + {field.options.map(option => ( + + + handleCustomFormFieldChange( + field._id!, + e.target.value, + ) + } + required={field.isRequired} + /> + + + ))} + + )} + {field.fieldType === 'multiple-choice' && field.options && ( + + {field.options.map(option => ( + + { + const currentValues = + customFormValues[field._id!] || ''; + const values = currentValues + ? currentValues.split(',').filter(v => v.trim()) + : []; + if (e.target.checked) { + values.push(option); + } else { + const index = values.indexOf(option); + if (index > -1) values.splice(index, 1); + } + handleCustomFormFieldChange( + field._id!, + values.join(', '), + ); + }} + /> + + + ))} + + )} + {field.fieldType === 'date' && ( + ) => + handleCustomFormFieldChange(field._id!, e.target.value) + } + InputLabelProps={{ shrink: true }} + required={field.isRequired} + /> + )} + {field.fieldType === 'time' && ( + ) => + handleCustomFormFieldChange(field._id!, e.target.value) + } + InputLabelProps={{ shrink: true }} + required={field.isRequired} + /> + )} + + ))} + + )} + {/* New client information input fields */} Client Name @@ -593,14 +792,24 @@ const BookingModal: React.FC = ({ } InputLabelProps={{ shrink: true }} inputProps={{ - min: status === 'Done' ? undefined : getCurrentDateTimeLocal(), + min: + status === 'Done' || status === 'Cancelled' + ? undefined + : getCurrentDateTimeLocal(), }} - error={isDateTimeInPast(datetime) && status !== 'Done'} + error={ + isDateTimeInPast(datetime) && + status !== 'Done' && + status !== 'Cancelled' + } helperText={ - isDateTimeInPast(datetime) && status !== 'Done' + isDateTimeInPast(datetime) && + status !== 'Done' && + status !== 'Cancelled' ? 'Cannot create booking for past time' - : status === 'Done' && isDateTimeInPast(datetime) - ? 'Recording completion time (past time allowed)' + : (status === 'Done' || status === 'Cancelled') && + isDateTimeInPast(datetime) + ? 'Recording completion time or cancelled time (past time allowed)' : '' } /> diff --git a/src/app/admin/booking/components/TaskManager/ViewFormModal.tsx b/src/app/admin/booking/components/TaskManager/ViewFormModal.tsx index a6aeab9..0ab0c05 100644 --- a/src/app/admin/booking/components/TaskManager/ViewFormModal.tsx +++ b/src/app/admin/booking/components/TaskManager/ViewFormModal.tsx @@ -15,6 +15,8 @@ import { import React from 'react'; import type { Service } from '@/features/service/serviceApi'; +import type { ServiceFormField } from '@/features/service-management/serviceManagementApi'; +import { useGetServiceFormFieldsQuery } from '@/features/service-management/serviceManagementApi'; interface Props { service: Service; @@ -175,6 +177,12 @@ const ViewFormModal: React.FC = ({ service, onClose }) => { const theme = useTheme(); useMediaQuery(theme.breakpoints.down('sm')); + // Get custom form fields for the service + const { data: customFormFields = [] } = useGetServiceFormFieldsQuery( + { serviceId: service.serviceId ?? service._id ?? '' }, + { skip: !(service.serviceId ?? service._id) }, + ); + const formatDateTime = (datetime?: string) => { if (!datetime) return 'No data'; const date = new Date(datetime); @@ -294,6 +302,51 @@ const ViewFormModal: React.FC = ({ service, onClose }) => { )} + {/* Custom Form Fields with Values */} + {customFormFields.length > 0 && ( + <> + + Custom Form Fields: + {customFormFields.map(field => { + // Find the corresponding form value from the service + const formValue = service.serviceFormValues?.find( + value => value.serviceFieldId === field._id, + ); + + return ( + + + {field.fieldName} + {field.isRequired && ( + * + )} + + + {formValue ? ( + + {formValue.answer} + + ) : ( + + Not filled + + )} + + + ); + })} + + + )} + Created By: diff --git a/src/features/service/serviceApi.ts b/src/features/service/serviceApi.ts index c53d8aa..cb81d3e 100644 --- a/src/features/service/serviceApi.ts +++ b/src/features/service/serviceApi.ts @@ -40,6 +40,10 @@ export interface Service { phoneNumber: string; address: string; }; + // Add: service form values for custom form fields + serviceFormValues?: { serviceFieldId: string; answer: string }[]; + // Add: service ID for finding the correct service + serviceId?: string; } // Pagination response wrapper From adc7aafc96f5811672e1ce1ccc5eb539f1ba40db Mon Sep 17 00:00:00 2001 From: Tim Tian Date: Sun, 24 Aug 2025 23:01:41 +0930 Subject: [PATCH 3/3] Fix bug --- .../components/TaskManager/BookingModal.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/admin/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index 26de89d..e9cb954 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -276,7 +276,7 @@ const BookingModal: React.FC = ({ { serviceId: selectedServiceId }, { skip: !selectedServiceId }, ); - + // Validate if selected datetime is in the past const isDateTimeInPast = (dateTimeString: string) => { if (!dateTimeString) return false; @@ -639,10 +639,16 @@ const BookingModal: React.FC = ({ type="checkbox" id={`${field._id}_${option}`} value={option} - checked={ - customFormValues[field._id!]?.includes(option) || - false - } + checked={(() => { + const currentValues = + customFormValues[field._id!] || ''; + if (!currentValues) return false; + const values = currentValues + .split(',') + .map(v => v.trim()) + .filter(v => v); + return values.includes(option); + })()} onChange={e => { const currentValues = customFormValues[field._id!] || '';