feat: Implement staff selection for STAFF resource type in resource modal
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
"saveChanges": "Save Changes",
|
"saveChanges": "Save Changes",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -161,6 +162,8 @@
|
|||||||
"editResource": "Edit Resource",
|
"editResource": "Edit Resource",
|
||||||
"resourceDetails": "Resource Details",
|
"resourceDetails": "Resource Details",
|
||||||
"resourceName": "Resource Name",
|
"resourceName": "Resource Name",
|
||||||
|
"resourceDescription": "Description",
|
||||||
|
"descriptionPlaceholder": "Optional description for this resource",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"resourceType": "Resource Type",
|
"resourceType": "Resource Type",
|
||||||
@@ -175,11 +178,23 @@
|
|||||||
"noResourcesFound": "No resources found.",
|
"noResourcesFound": "No resources found.",
|
||||||
"addNewResource": "Add New Resource",
|
"addNewResource": "Add New Resource",
|
||||||
"createResource": "Create Resource",
|
"createResource": "Create Resource",
|
||||||
|
"updateResource": "Update Resource",
|
||||||
"staffMember": "Staff Member",
|
"staffMember": "Staff Member",
|
||||||
"room": "Room",
|
"room": "Room",
|
||||||
"equipment": "Equipment",
|
"equipment": "Equipment",
|
||||||
"resourceNote": "Resources are placeholders for scheduling. Staff can be assigned to appointments separately.",
|
"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": {
|
"services": {
|
||||||
"title": "Services",
|
"title": "Services",
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useEffect, useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ResourceType, User } from '../types';
|
import { ResourceType, User, Resource } from '../types';
|
||||||
import { useResources, useCreateResource } from '../hooks/useBusiness';
|
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
|
||||||
import { useAppointments } from '../hooks/useAppointments';
|
import { useAppointments } from '../hooks/useAppointments';
|
||||||
|
import { useStaff, StaffMember } from '../hooks/useStaff';
|
||||||
import ResourceCalendar from '../components/ResourceCalendar';
|
import ResourceCalendar from '../components/ResourceCalendar';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Home,
|
Home,
|
||||||
Wrench,
|
Wrench,
|
||||||
Eye,
|
Eye,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Settings,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
||||||
@@ -38,11 +40,44 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// All hooks must be called at the top, before any conditional returns
|
// All hooks must be called at the top, before any conditional returns
|
||||||
const { data: resources = [], isLoading, error } = useResources();
|
const { data: resources = [], isLoading, error } = useResources();
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = React.useState(false);
|
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||||
const [newResourceType, setNewResourceType] = React.useState<ResourceType>('STAFF');
|
const [editingResource, setEditingResource] = React.useState<Resource | null>(null);
|
||||||
const [newResourceName, setNewResourceName] = React.useState('');
|
const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null);
|
||||||
const [selectedResource, setSelectedResource] = 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 createResourceMutation = useCreateResource();
|
||||||
|
const updateResourceMutation = useUpdateResource();
|
||||||
|
|
||||||
// Fetch ALL appointments (not filtered by date) to count per resource
|
// Fetch ALL appointments (not filtered by date) to count per resource
|
||||||
// We filter client-side for future appointments
|
// We filter client-side for future appointments
|
||||||
@@ -61,19 +96,103 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
return counts;
|
return counts;
|
||||||
}, [allAppointments]);
|
}, [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();
|
e.preventDefault();
|
||||||
|
|
||||||
createResourceMutation.mutate({
|
if (formType === 'STAFF' && !selectedStaffId) {
|
||||||
name: newResourceName,
|
alert(t('resources.staffRequired')); // Basic alert for now
|
||||||
type: newResourceType
|
return;
|
||||||
}, {
|
}
|
||||||
onSuccess: () => {
|
|
||||||
setIsAddModalOpen(false);
|
const resourceData: {
|
||||||
setNewResourceName('');
|
name: string;
|
||||||
setNewResourceType('STAFF');
|
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) {
|
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>
|
<p className="text-gray-500 dark:text-gray-400">{t('resources.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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} />
|
<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.resourceName')}</th>
|
||||||
<th className="px-6 py-4 font-medium">{t('resources.type')}</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.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">{t('scheduler.status')}</th>
|
||||||
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
{resources.map((resource: any) => {
|
{resources.map((resource: Resource) => {
|
||||||
return (
|
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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
<span className="text-xs text-gray-400">{t('resources.appointments')}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<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">
|
<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')}
|
{t('resources.active')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<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
|
<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"
|
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}`}
|
title={`${t('resources.viewCalendar')} - ${resource.name}`}
|
||||||
>
|
>
|
||||||
<Eye size={14} /> {t('resources.viewCalendar')}
|
<Eye size={14} /> {t('resources.viewCalendar')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -187,64 +320,200 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Resource Modal */}
|
{/* Create/Edit Resource Modal */}
|
||||||
{isAddModalOpen && (
|
{isModalOpen && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<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="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">
|
<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>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
|
{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>
|
<span className="sr-only">{t('common.close')}</span>
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<X size={24} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleAddResource} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
<div>
|
{/* Resource Type */}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceType')}</label>
|
<div>
|
||||||
<select
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
value={newResourceType}
|
{t('resources.resourceType')}
|
||||||
onChange={(e) => setNewResourceType(e.target.value as ResourceType)}
|
</label>
|
||||||
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"
|
<select
|
||||||
>
|
value={formType}
|
||||||
<option value="STAFF">{t('resources.staffMember')}</option>
|
onChange={(e) => {
|
||||||
<option value="ROOM">{t('resources.room')}</option>
|
setFormType(e.target.value as ResourceType);
|
||||||
<option value="EQUIPMENT">{t('resources.equipment')}</option>
|
setSelectedStaffId(null); // Clear staff selection if type changes
|
||||||
</select>
|
setStaffSearchQuery('');
|
||||||
</div>
|
}}
|
||||||
|
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>
|
{/* Staff Member Selector (Conditional) */}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceName')}</label>
|
{formType === 'STAFF' && (
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<label htmlFor="staff-member" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
value={newResourceName}
|
{t('resources.assignStaff')} <span className="text-red-500">*</span>
|
||||||
onChange={(e) => setNewResourceName(e.target.value)}
|
</label>
|
||||||
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"
|
<input
|
||||||
placeholder={newResourceType === 'STAFF' ? 'e.g. Sarah (Stylist)' : newResourceType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'}
|
id="staff-member"
|
||||||
required
|
type="text"
|
||||||
/>
|
ref={staffInputRef}
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
value={staffSearchQuery}
|
||||||
{t('resources.resourceNote')}
|
onChange={(e) => {
|
||||||
</p>
|
setStaffSearchQuery(e.target.value);
|
||||||
</div>
|
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">
|
{/* Resource Name */}
|
||||||
<button
|
<div>
|
||||||
type="button"
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
onClick={() => setIsAddModalOpen(false)}
|
{t('resources.resourceName')}
|
||||||
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"
|
</label>
|
||||||
>
|
<input
|
||||||
{t('common.cancel')}
|
type="text"
|
||||||
</button>
|
value={formName}
|
||||||
<button
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
type="submit"
|
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"
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
|
placeholder={formType === 'STAFF' ? 'e.g. Sarah (Stylist)' : formType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'}
|
||||||
>
|
required
|
||||||
{t('resources.createResource')}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,15 +521,15 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource Calendar Modal */}
|
{/* Resource Calendar Modal */}
|
||||||
{selectedResource && (
|
{calendarResource && (
|
||||||
<ResourceCalendar
|
<ResourceCalendar
|
||||||
resourceId={selectedResource.id}
|
resourceId={calendarResource.id}
|
||||||
resourceName={selectedResource.name}
|
resourceName={calendarResource.name}
|
||||||
onClose={() => setSelectedResource(null)}
|
onClose={() => setCalendarResource(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Resources;
|
export default Resources;
|
||||||
|
|||||||
Reference in New Issue
Block a user