diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1a5c952..bf9d200 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4,6 +4,7 @@ "error": "Error", "success": "Success", "save": "Save", + "saving": "Saving...", "saveChanges": "Save Changes", "cancel": "Cancel", "delete": "Delete", @@ -161,6 +162,8 @@ "editResource": "Edit Resource", "resourceDetails": "Resource Details", "resourceName": "Resource Name", + "resourceDescription": "Description", + "descriptionPlaceholder": "Optional description for this resource", "name": "Name", "type": "Type", "resourceType": "Resource Type", @@ -175,11 +178,23 @@ "noResourcesFound": "No resources found.", "addNewResource": "Add New Resource", "createResource": "Create Resource", + "updateResource": "Update Resource", "staffMember": "Staff Member", "room": "Room", "equipment": "Equipment", "resourceNote": "Resources are placeholders for scheduling. Staff can be assigned to appointments separately.", - "errorLoading": "Error loading resources" + "errorLoading": "Error loading resources", + "multilaneMode": "Multi-lane Mode", + "multilaneDescription": "Allow multiple simultaneous appointments", + "numberOfLanes": "Number of Lanes", + "lanesHelp": "How many appointments can be scheduled at the same time", + "capacity": "Capacity", + "simultaneous": "simultaneous", + "atATime": "at a time", + "assignStaff": "Assign Staff Member", + "searchStaffPlaceholder": "Search by name or email...", + "noMatchingStaff": "No matching staff found.", + "staffRequired": "Staff member is required for STAFF resource type." }, "services": { "title": "Services", diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index 24dd87b..b086996 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -1,19 +1,21 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { ResourceType, User } from '../types'; -import { useResources, useCreateResource } from '../hooks/useBusiness'; +import { ResourceType, User, Resource } from '../types'; +import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources'; import { useAppointments } from '../hooks/useAppointments'; +import { useStaff, StaffMember } from '../hooks/useStaff'; import ResourceCalendar from '../components/ResourceCalendar'; import Portal from '../components/Portal'; import { Plus, - MoreHorizontal, User as UserIcon, Home, Wrench, Eye, - Calendar + Calendar, + Settings, + X } from 'lucide-react'; const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { @@ -38,11 +40,44 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => const { t } = useTranslation(); // All hooks must be called at the top, before any conditional returns const { data: resources = [], isLoading, error } = useResources(); - const [isAddModalOpen, setIsAddModalOpen] = React.useState(false); - const [newResourceType, setNewResourceType] = React.useState('STAFF'); - const [newResourceName, setNewResourceName] = React.useState(''); - const [selectedResource, setSelectedResource] = React.useState<{ id: string; name: string } | null>(null); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [editingResource, setEditingResource] = React.useState(null); + const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null); + + // Form state + const [formType, setFormType] = React.useState('STAFF'); + const [formName, setFormName] = React.useState(''); + const [formDescription, setFormDescription] = React.useState(''); + const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1); + const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false); + const [formSavedLaneCount, setFormSavedLaneCount] = React.useState(undefined); + + // Staff selection state + const [selectedStaffId, setSelectedStaffId] = useState(null); + const [staffSearchQuery, setStaffSearchQuery] = useState(''); + const [showStaffDropdown, setShowStaffDropdown] = useState(false); + const staffInputRef = useRef(null); + const staffDropdownRef = useRef(null); + + // Fetch staff members for autocomplete + const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery }); + + // Filter staff members based on search query (client-side filtering for immediate feedback) + const filteredStaff = useMemo(() => { + if (!staffSearchQuery) return staffMembers; + const query = staffSearchQuery.toLowerCase(); + return staffMembers.filter( + (s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query) + ); + }, [staffMembers, staffSearchQuery]); + + // Get selected staff member details + const selectedStaff = useMemo(() => { + return staffMembers.find((s) => s.id === selectedStaffId) || null; + }, [staffMembers, selectedStaffId]); + const createResourceMutation = useCreateResource(); + const updateResourceMutation = useUpdateResource(); // Fetch ALL appointments (not filtered by date) to count per resource // We filter client-side for future appointments @@ -61,19 +96,103 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => return counts; }, [allAppointments]); - const handleAddResource = (e: React.FormEvent) => { + // Reset form when modal opens/closes or editing resource changes + useEffect(() => { + if (editingResource) { + setFormType(editingResource.type); + setFormName(editingResource.name); + setFormDescription(''); // TODO: Add description to Resource type when backend supports it + setFormMaxConcurrent(editingResource.maxConcurrentEvents); + setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1); + setFormSavedLaneCount(editingResource.savedLaneCount); + // Pre-fill staff if editing a STAFF resource + if (editingResource.type === 'STAFF' && editingResource.userId) { + setSelectedStaffId(editingResource.userId); + // Find the staff member to set the initial search query (display name) + const staff = staffMembers.find(s => s.id === editingResource.userId); + setStaffSearchQuery(staff ? staff.name : ''); + } else { + setSelectedStaffId(null); + setStaffSearchQuery(''); + } + } else { + setFormType('STAFF'); + setFormName(''); + setFormDescription(''); + setFormMaxConcurrent(1); + setFormMultilaneEnabled(false); + setFormSavedLaneCount(undefined); + setSelectedStaffId(null); // Clear selected staff when creating new + setStaffSearchQuery(''); + } + }, [editingResource, staffMembers]); + + const openCreateModal = () => { + setEditingResource(null); + setIsModalOpen(true); + }; + + const openEditModal = (resource: Resource) => { + setEditingResource(resource); + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + setEditingResource(null); + }; + + const handleMultilaneToggle = (enabled: boolean) => { + setFormMultilaneEnabled(enabled); + if (enabled) { + // Restore saved lane count or default to 2 + setFormMaxConcurrent(formSavedLaneCount ?? 2); + } else { + // Save current lane count before disabling + if (formMaxConcurrent > 1) { + setFormSavedLaneCount(formMaxConcurrent); + } + setFormMaxConcurrent(1); + } + }; + + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - createResourceMutation.mutate({ - name: newResourceName, - type: newResourceType - }, { - onSuccess: () => { - setIsAddModalOpen(false); - setNewResourceName(''); - setNewResourceType('STAFF'); - } - }); + if (formType === 'STAFF' && !selectedStaffId) { + alert(t('resources.staffRequired')); // Basic alert for now + return; + } + + const resourceData: { + name: string; + type: ResourceType; + maxConcurrentEvents: number; + savedLaneCount: number | undefined; + userId?: string; + } = { + name: formName, + type: formType, + maxConcurrentEvents: formMaxConcurrent, + savedLaneCount: formMultilaneEnabled ? undefined : formSavedLaneCount, + }; + + if (formType === 'STAFF' && selectedStaffId) { + resourceData.userId = selectedStaffId; + } + + if (editingResource) { + updateResourceMutation.mutate({ + id: editingResource.id, + updates: resourceData + }, { + onSuccess: closeModal + }); + } else { + createResourceMutation.mutate(resourceData, { + onSuccess: closeModal + }); + } }; if (isLoading) { @@ -105,7 +224,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) =>

{t('resources.description')}

- @@ -187,64 +320,200 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => - {/* Add Resource Modal */} - {isAddModalOpen && ( + {/* Create/Edit Resource Modal */} + {isModalOpen && (
-

{t('resources.addNewResource')}

-
-
-
- - -
+ + {/* Resource Type */} +
+ + +
-
- - setNewResourceName(e.target.value)} - className="w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500" - placeholder={newResourceType === 'STAFF' ? 'e.g. Sarah (Stylist)' : newResourceType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'} - required - /> -

- {t('resources.resourceNote')} -

-
+ {/* Staff Member Selector (Conditional) */} + {formType === 'STAFF' && ( +
+ + { + setStaffSearchQuery(e.target.value); + setShowStaffDropdown(true); + }} + onFocus={() => setShowStaffDropdown(true)} + onBlur={() => setTimeout(() => setShowStaffDropdown(false), 150)} // Delay to allow click on dropdown + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600" + placeholder={t('resources.searchStaffPlaceholder')} + required={formType === 'STAFF'} + aria-autocomplete="list" + aria-controls="staff-suggestions" + /> + {showStaffDropdown && filteredStaff.length > 0 && ( +
+ {filteredStaff.map((staff) => ( +
{ + setSelectedStaffId(staff.id); + setStaffSearchQuery(staff.name); + setShowStaffDropdown(false); + }} + role="option" + aria-selected={selectedStaffId === staff.id} + > + {staff.name} ({staff.email}) +
+ ))} +
+ )} + {formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && ( +

+ {t('resources.noMatchingStaff')} +

+ )} + {formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && ( +

+ {t('resources.staffRequired')} +

+ )} +
+ )} -
- - -
+ {/* Resource Name */} +
+ + setFormName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600" + placeholder={formType === 'STAFF' ? 'e.g. Sarah (Stylist)' : formType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'} + required + /> +
+ + {/* Description */} +
+ +