feat: Implement staff selection for STAFF resource type in resource modal
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user