feat: Implement staff selection for STAFF resource type in resource modal

This commit is contained in:
poduck
2025-11-27 22:08:15 -05:00
parent 86a4e87ed6
commit a7c756a8ec
2 changed files with 367 additions and 83 deletions

View File

@@ -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",

View File

@@ -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<ResourcesProps> = ({ 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<ResourceType>('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<Resource | null>(null);
const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null);
// Form state
const [formType, setFormType] = React.useState<ResourceType>('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<number | undefined>(undefined);
// Staff selection state
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
const [staffSearchQuery, setStaffSearchQuery] = useState('');
const [showStaffDropdown, setShowStaffDropdown] = useState(false);
const staffInputRef = useRef<HTMLInputElement>(null);
const staffDropdownRef = useRef<HTMLDivElement>(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<ResourcesProps> = ({ 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<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
<p className="text-gray-500 dark:text-gray-400">{t('resources.description')}</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
onClick={openCreateModal}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
@@ -122,14 +241,19 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
<th className="px-6 py-4 font-medium">{t('resources.resourceName')}</th>
<th className="px-6 py-4 font-medium">{t('resources.type')}</th>
<th className="px-6 py-4 font-medium">{t('resources.upcoming')}</th>
<th className="px-6 py-4 font-medium">{t('resources.capacity')}</th>
<th className="px-6 py-4 font-medium">{t('scheduler.status')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{resources.map((resource: any) => {
{resources.map((resource: Resource) => {
return (
<tr key={resource.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<tr
key={resource.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer"
onClick={() => openEditModal(resource)}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
@@ -155,23 +279,32 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
<span className="text-xs text-gray-400">{t('resources.appointments')}</span>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
resource.maxConcurrentEvents > 1
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{resource.maxConcurrentEvents > 1
? `${resource.maxConcurrentEvents} ${t('resources.simultaneous')}`
: `1 ${t('resources.atATime')}`
}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t('resources.active')}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedResource({ id: resource.id, name: resource.name })}
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
title={`${t('resources.viewCalendar')} - ${resource.name}`}
>
<Eye size={14} /> {t('resources.viewCalendar')}
</button>
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
@@ -187,64 +320,200 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
</div>
</div>
{/* Add Resource Modal */}
{isAddModalOpen && (
{/* Create/Edit Resource Modal */}
{isModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('resources.addNewResource')}</h3>
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingResource ? t('resources.editResource') : t('resources.addNewResource')}
</h3>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<span className="sr-only">{t('common.close')}</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<X size={24} />
</button>
</div>
<form onSubmit={handleAddResource} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceType')}</label>
<select
value={newResourceType}
onChange={(e) => setNewResourceType(e.target.value as ResourceType)}
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"
>
<option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option>
<option value="EQUIPMENT">{t('resources.equipment')}</option>
</select>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Resource Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.resourceType')}
</label>
<select
value={formType}
onChange={(e) => {
setFormType(e.target.value as ResourceType);
setSelectedStaffId(null); // Clear staff selection if type changes
setStaffSearchQuery('');
}}
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"
disabled={!!editingResource}
>
<option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option>
<option value="EQUIPMENT">{t('resources.equipment')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceName')}</label>
<input
type="text"
value={newResourceName}
onChange={(e) => 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
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('resources.resourceNote')}
</p>
</div>
{/* Staff Member Selector (Conditional) */}
{formType === 'STAFF' && (
<div className="relative">
<label htmlFor="staff-member" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.assignStaff')} <span className="text-red-500">*</span>
</label>
<input
id="staff-member"
type="text"
ref={staffInputRef}
value={staffSearchQuery}
onChange={(e) => {
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 && (
<div
ref={staffDropdownRef}
id="staff-suggestions"
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-60 overflow-auto"
role="listbox"
>
{filteredStaff.map((staff) => (
<div
key={staff.id}
className="p-2 text-sm text-gray-900 dark:text-white hover:bg-brand-50 dark:hover:bg-brand-900/30 cursor-pointer"
onClick={() => {
setSelectedStaffId(staff.id);
setStaffSearchQuery(staff.name);
setShowStaffDropdown(false);
}}
role="option"
aria-selected={selectedStaffId === staff.id}
>
{staff.name} ({staff.email})
</div>
))}
</div>
)}
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('resources.noMatchingStaff')}
</p>
)}
{formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('resources.staffRequired')}
</p>
)}
</div>
)}
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
>
{t('resources.createResource')}
</button>
</div>
{/* Resource Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.resourceName')}
</label>
<input
type="text"
value={formName}
onChange={(e) => 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
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.resourceDescription')}
</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(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={t('resources.descriptionPlaceholder')}
rows={2}
/>
</div>
{/* Multilane Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('resources.multilaneMode')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('resources.multilaneDescription')}
</p>
</div>
<button
type="button"
onClick={() => handleMultilaneToggle(!formMultilaneEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 ${
formMultilaneEnabled ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
role="switch"
aria-checked={formMultilaneEnabled}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formMultilaneEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Number of Lanes (only shown when multilane is enabled) */}
{formMultilaneEnabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.numberOfLanes')}
</label>
<input
type="number"
min={2}
max={10}
value={formMaxConcurrent}
onChange={(e) => setFormMaxConcurrent(Math.max(2, parseInt(e.target.value) || 2))}
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"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('resources.lanesHelp')}
</p>
</div>
)}
{/* Submit Buttons */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={createResourceMutation.isPending || updateResourceMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createResourceMutation.isPending || updateResourceMutation.isPending
? t('common.saving')
: editingResource
? t('resources.updateResource')
: t('resources.createResource')
}
</button>
</div>
</form>
</div>
</div>
@@ -252,15 +521,15 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
)}
{/* Resource Calendar Modal */}
{selectedResource && (
{calendarResource && (
<ResourceCalendar
resourceId={selectedResource.id}
resourceName={selectedResource.name}
onClose={() => setSelectedResource(null)}
resourceId={calendarResource.id}
resourceName={calendarResource.name}
onClose={() => setCalendarResource(null)}
/>
)}
</div>
);
};
export default Resources;
export default Resources;