feat(staff): Restrict staff permissions and add schedule view
- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs - Frontend: Hide management sidebar links from staff members - Add StaffSchedule page with vertical timeline view of appointments - Add StaffHelp page with staff-specific documentation - Return linked_resource_id and can_edit_schedule in user profile for staff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useResources, useCreateResource, useUpdateResource } from '../hooks/use
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useStaff, StaffMember } from '../hooks/useStaff';
|
||||
import ResourceCalendar from '../components/ResourceCalendar';
|
||||
import ResourceDetailModal from '../components/ResourceDetailModal';
|
||||
import Portal from '../components/Portal';
|
||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||
import {
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Pencil,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
||||
@@ -46,6 +48,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
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);
|
||||
const [detailResource, setDetailResource] = React.useState<Resource | null>(null);
|
||||
|
||||
// Calculate over-quota resources (will be auto-archived when grace period ends)
|
||||
const overQuotaResourceIds = useMemo(
|
||||
@@ -60,6 +63,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1);
|
||||
const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false);
|
||||
const [formSavedLaneCount, setFormSavedLaneCount] = React.useState<number | undefined>(undefined);
|
||||
const [formUserCanEditSchedule, setFormUserCanEditSchedule] = React.useState(false);
|
||||
|
||||
// Staff selection state
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
||||
@@ -181,6 +185,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
setFormMaxConcurrent(editingResource.maxConcurrentEvents);
|
||||
setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1);
|
||||
setFormSavedLaneCount(editingResource.savedLaneCount);
|
||||
setFormUserCanEditSchedule(editingResource.userCanEditSchedule ?? false);
|
||||
// Pre-fill staff if editing a STAFF resource
|
||||
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
||||
setSelectedStaffId(editingResource.userId);
|
||||
@@ -197,6 +202,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
setFormMaxConcurrent(1);
|
||||
setFormMultilaneEnabled(false);
|
||||
setFormSavedLaneCount(undefined);
|
||||
setFormUserCanEditSchedule(false);
|
||||
setSelectedStaffId(null);
|
||||
setStaffSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
@@ -258,6 +264,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
maxConcurrentEvents: number;
|
||||
savedLaneCount: number | undefined;
|
||||
userId?: string;
|
||||
userCanEditSchedule?: boolean;
|
||||
} = {
|
||||
name: formName,
|
||||
type: formType,
|
||||
@@ -267,6 +274,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
|
||||
if (formType === 'STAFF' && selectedStaffId) {
|
||||
resourceData.userId = selectedStaffId;
|
||||
resourceData.userCanEditSchedule = formUserCanEditSchedule;
|
||||
}
|
||||
|
||||
if (editingResource) {
|
||||
@@ -409,6 +417,15 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{resource.type === 'STAFF' && resource.userId && (
|
||||
<button
|
||||
onClick={() => setDetailResource(resource)}
|
||||
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||
title={t('resources.trackLocation', 'Track Location')}
|
||||
>
|
||||
<MapPin size={14} /> {t('resources.trackLocation', 'Track')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
@@ -646,6 +663,35 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allow User to Edit Schedule Toggle (only for STAFF type) */}
|
||||
{formType === 'STAFF' && selectedStaffId && (
|
||||
<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.allowEditSchedule', 'Allow User to Edit Schedule')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('resources.allowEditScheduleDescription', 'Let this staff member reschedule and resize their own appointments in the mobile app')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormUserCanEditSchedule(!formUserCanEditSchedule)}
|
||||
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 ${
|
||||
formUserCanEditSchedule ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={formUserCanEditSchedule}
|
||||
>
|
||||
<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 ${
|
||||
formUserCanEditSchedule ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@@ -682,6 +728,14 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
onClose={() => setCalendarResource(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resource Detail Modal (with location tracking) */}
|
||||
{detailResource && (
|
||||
<ResourceDetailModal
|
||||
resource={detailResource}
|
||||
onClose={() => setDetailResource(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
460
frontend/src/pages/StaffSchedule.tsx
Normal file
460
frontend/src/pages/StaffSchedule.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
format,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
addDays,
|
||||
subDays,
|
||||
differenceInMinutes,
|
||||
addMinutes,
|
||||
isSameDay,
|
||||
parseISO,
|
||||
} from 'date-fns';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../api/client';
|
||||
import { User as UserType } from '../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface StaffScheduleProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
customer_name?: string;
|
||||
service_name?: string;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
const START_HOUR = 6; // 6 AM
|
||||
const END_HOUR = 22; // 10 PM
|
||||
|
||||
const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [draggedJob, setDraggedJob] = useState<Job | null>(null);
|
||||
|
||||
const canEditSchedule = user.can_edit_schedule ?? false;
|
||||
|
||||
// Get the resource ID linked to this user (from the user object)
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
|
||||
// Fetch appointments for the current staff member's resource
|
||||
const { data: jobs = [], isLoading } = useQuery({
|
||||
queryKey: ['staff-jobs', format(currentDate, 'yyyy-MM-dd'), userResourceId],
|
||||
queryFn: async () => {
|
||||
if (!userResourceId) return [];
|
||||
|
||||
const start = startOfDay(currentDate).toISOString();
|
||||
const end = endOfDay(currentDate).toISOString();
|
||||
const response = await apiClient.get('/appointments/', {
|
||||
params: {
|
||||
resource: userResourceId,
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
},
|
||||
});
|
||||
|
||||
// Transform to Job format
|
||||
return response.data.map((apt: any) => ({
|
||||
id: apt.id,
|
||||
title: apt.title || apt.service_name || 'Appointment',
|
||||
start_time: apt.start_time,
|
||||
end_time: apt.end_time,
|
||||
status: apt.status,
|
||||
notes: apt.notes,
|
||||
customer_name: apt.customer_name,
|
||||
service_name: apt.service_name,
|
||||
}));
|
||||
},
|
||||
enabled: !!userResourceId,
|
||||
});
|
||||
|
||||
// Mutation for rescheduling
|
||||
const rescheduleMutation = useMutation({
|
||||
mutationFn: async ({ jobId, newStart }: { jobId: number; newStart: Date }) => {
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) throw new Error('Job not found');
|
||||
|
||||
const duration = differenceInMinutes(parseISO(job.end_time), parseISO(job.start_time));
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
await apiClient.patch(`/appointments/${jobId}/`, {
|
||||
start_time: newStart.toISOString(),
|
||||
end_time: newEnd.toISOString(),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-jobs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['appointments'] });
|
||||
toast.success(t('staff.jobRescheduled', 'Job rescheduled successfully'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('staff.rescheduleError', 'Failed to reschedule job'));
|
||||
},
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Generate time slots
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots = [];
|
||||
for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
|
||||
slots.push({
|
||||
hour,
|
||||
label: format(new Date().setHours(hour, 0, 0, 0), 'h a'),
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}, []);
|
||||
|
||||
// Calculate job positions
|
||||
const jobsWithPositions = useMemo(() => {
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
const jobDate = parseISO(job.start_time);
|
||||
return isSameDay(jobDate, currentDate);
|
||||
})
|
||||
.map((job) => {
|
||||
const startTime = parseISO(job.start_time);
|
||||
const endTime = parseISO(job.end_time);
|
||||
const startHour = startTime.getHours() + startTime.getMinutes() / 60;
|
||||
const endHour = endTime.getHours() + endTime.getMinutes() / 60;
|
||||
|
||||
const top = (startHour - START_HOUR) * HOUR_HEIGHT;
|
||||
const height = (endHour - startHour) * HOUR_HEIGHT;
|
||||
|
||||
return {
|
||||
...job,
|
||||
top: Math.max(0, top),
|
||||
height: Math.max(30, height),
|
||||
};
|
||||
});
|
||||
}, [jobs, currentDate]);
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
const jobId = parseInt(event.active.id.toString().replace('job-', ''));
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
setDraggedJob(job || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setDraggedJob(null);
|
||||
|
||||
if (!canEditSchedule) return;
|
||||
|
||||
const { active, delta } = event;
|
||||
if (!active || Math.abs(delta.y) < 10) return;
|
||||
|
||||
const jobId = parseInt(active.id.toString().replace('job-', ''));
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) return;
|
||||
|
||||
// Calculate new time based on drag delta
|
||||
const minutesDelta = Math.round((delta.y / HOUR_HEIGHT) * 60);
|
||||
const snappedMinutes = Math.round(minutesDelta / 15) * 15; // Snap to 15-minute intervals
|
||||
|
||||
const originalStart = parseISO(job.start_time);
|
||||
const newStart = addMinutes(originalStart, snappedMinutes);
|
||||
|
||||
// Validate new time is within bounds
|
||||
const newHour = newStart.getHours();
|
||||
if (newHour < START_HOUR || newHour >= END_HOUR) {
|
||||
toast.error(t('staff.timeOutOfBounds', 'Cannot schedule outside business hours'));
|
||||
return;
|
||||
}
|
||||
|
||||
rescheduleMutation.mutate({ jobId, newStart });
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'SCHEDULED':
|
||||
case 'CONFIRMED':
|
||||
return 'bg-blue-100 border-blue-500 text-blue-800 dark:bg-blue-900/30 dark:border-blue-400 dark:text-blue-300';
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-yellow-100 border-yellow-500 text-yellow-800 dark:bg-yellow-900/30 dark:border-yellow-400 dark:text-yellow-300';
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-100 border-green-500 text-green-800 dark:bg-green-900/30 dark:border-green-400 dark:text-green-300';
|
||||
case 'CANCELLED':
|
||||
case 'NO_SHOW':
|
||||
return 'bg-red-100 border-red-500 text-red-800 dark:bg-red-900/30 dark:border-red-400 dark:text-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 border-gray-500 text-gray-800 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate((d) => (direction === 'prev' ? subDays(d, 1) : addDays(d, 1)));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
// Show message if no resource is linked
|
||||
if (!userResourceId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staff.mySchedule', 'My Schedule')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-md">
|
||||
<Calendar size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('staff.noResourceLinked', 'No Schedule Available')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'staff.noResourceLinkedDesc',
|
||||
'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staff.mySchedule', 'My Schedule')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{canEditSchedule
|
||||
? t('staff.dragToReschedule', 'Drag jobs to reschedule them')
|
||||
: t('staff.viewOnlySchedule', 'View your scheduled jobs for the day')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.today', 'Today')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<Calendar size={16} className="text-gray-500 dark:text-gray-400" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{format(currentDate, 'EEEE, MMMM d, yyyy')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Time Column */}
|
||||
<div className="w-20 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
{timeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="border-b border-gray-100 dark:border-gray-700/50 text-right pr-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{slot.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events Column */}
|
||||
<div
|
||||
className="flex-1 relative"
|
||||
style={{ height: (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT }}
|
||||
>
|
||||
{/* Hour Grid Lines */}
|
||||
{timeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="absolute left-0 right-0 border-b border-gray-100 dark:border-gray-700/50"
|
||||
style={{ top: (slot.hour - START_HOUR) * HOUR_HEIGHT, height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Current Time Line */}
|
||||
{isSameDay(currentDate, new Date()) && (
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t-2 border-red-500 z-20"
|
||||
style={{
|
||||
top:
|
||||
(new Date().getHours() +
|
||||
new Date().getMinutes() / 60 -
|
||||
START_HOUR) *
|
||||
HOUR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -left-1 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs */}
|
||||
{jobsWithPositions.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
id={`job-${job.id}`}
|
||||
className={`absolute left-2 right-2 rounded-lg border-l-4 p-3 transition-shadow ${getStatusColor(job.status)} ${
|
||||
canEditSchedule ? 'cursor-grab active:cursor-grabbing hover:shadow-lg' : ''
|
||||
}`}
|
||||
style={{
|
||||
top: job.top,
|
||||
height: job.height,
|
||||
minHeight: 60,
|
||||
}}
|
||||
draggable={canEditSchedule}
|
||||
>
|
||||
<div className="flex items-start justify-between h-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{canEditSchedule && (
|
||||
<GripVertical
|
||||
size={14}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold text-sm truncate">
|
||||
{job.title || job.service_name || 'Appointment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs opacity-80">
|
||||
<Clock size={12} />
|
||||
<span>
|
||||
{format(parseISO(job.start_time), 'h:mm a')} -{' '}
|
||||
{format(parseISO(job.end_time), 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{job.customer_name && (
|
||||
<div className="flex items-center gap-1.5 text-xs opacity-80">
|
||||
<User size={12} />
|
||||
<span className="truncate">{job.customer_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
job.status === 'IN_PROGRESS'
|
||||
? 'bg-yellow-200 text-yellow-800'
|
||||
: 'bg-white/50 dark:bg-gray-900/30'
|
||||
}`}
|
||||
>
|
||||
{job.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{jobsWithPositions.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Calendar
|
||||
size={48}
|
||||
className="mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
||||
/>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
{t('staff.noJobsToday', 'No jobs scheduled')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'staff.noJobsDescription',
|
||||
'You have no jobs scheduled for this day'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{draggedJob ? (
|
||||
<div className="p-3 bg-white dark:bg-gray-700 border-l-4 border-blue-500 rounded-lg shadow-xl opacity-90 w-64">
|
||||
<div className="font-semibold text-sm text-gray-900 dark:text-white">
|
||||
{draggedJob.title || draggedJob.service_name || 'Appointment'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<Clock size={12} />
|
||||
<span>
|
||||
{format(parseISO(draggedJob.start_time), 'h:mm a')} -{' '}
|
||||
{format(parseISO(draggedJob.end_time), 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffSchedule;
|
||||
285
frontend/src/pages/help/StaffHelp.tsx
Normal file
285
frontend/src/pages/help/StaffHelp.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Staff Help Guide
|
||||
*
|
||||
* Simplified documentation for staff members.
|
||||
* Only covers features that staff have access to.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
CalendarOff,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Ticket,
|
||||
} from 'lucide-react';
|
||||
import { User } from '../../types';
|
||||
|
||||
interface StaffHelpProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const canAccessTickets = user.can_access_tickets ?? false;
|
||||
const canEditSchedule = user.can_edit_schedule ?? false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffHelp.title', 'Staff Guide')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Introduction */}
|
||||
<section className="mb-12">
|
||||
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('staffHelp.welcome', 'Welcome to SmoothSchedule')}
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.intro',
|
||||
'This guide covers everything you need to know as a staff member. You can view your schedule, manage your availability, and stay updated on your assignments.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Dashboard Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<LayoutDashboard size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffHelp.dashboard.title', 'Dashboard')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.dashboard.description',
|
||||
"Your dashboard provides a quick overview of your day. Here you can see today's summary and any important updates."
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.dashboard.feature1', 'View daily summary and stats')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.dashboard.feature2', 'Quick access to your schedule')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* My Schedule Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffHelp.schedule.title', 'My Schedule')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.schedule.description',
|
||||
'The My Schedule page shows a vertical timeline of all your jobs for the day. You can navigate between days to see past and future appointments.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.schedule.features', 'Features')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-brand-500" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature1', 'See all your jobs in a vertical timeline')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>
|
||||
{t(
|
||||
'staffHelp.schedule.feature2',
|
||||
'View customer name and appointment details'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature3', 'Navigate between days using arrows')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature4', 'See current time indicator on today\'s view')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{canEditSchedule ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||
<GripVertical size={18} className="text-green-500" />
|
||||
{t('staffHelp.schedule.rescheduleTitle', 'Drag to Reschedule')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.schedule.rescheduleDesc',
|
||||
'You have permission to reschedule your jobs. Simply drag a job up or down on the timeline to move it to a different time slot. Changes will be saved automatically.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.schedule.viewOnly',
|
||||
'Your schedule is view-only. Contact a manager if you need to reschedule an appointment.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* My Availability Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
|
||||
<CalendarOff size={20} className="text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.title', 'My Availability')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.availability.description',
|
||||
'Use the My Availability page to set times when you are not available for bookings. This helps managers and the booking system know when not to schedule you.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.availability.howTo', 'How to Block Time')}
|
||||
</h3>
|
||||
<ol className="space-y-2 text-sm text-gray-600 dark:text-gray-300 list-decimal list-inside mb-4">
|
||||
<li>{t('staffHelp.availability.step1', 'Click "Add Time Block" button')}</li>
|
||||
<li>{t('staffHelp.availability.step2', 'Select the date and time range')}</li>
|
||||
<li>{t('staffHelp.availability.step3', 'Add an optional reason (e.g., "Vacation", "Doctor appointment")')}</li>
|
||||
<li>{t('staffHelp.availability.step4', 'Choose if it repeats (one-time, weekly, etc.)')}</li>
|
||||
<li>{t('staffHelp.availability.step5', 'Save your time block')}</li>
|
||||
</ol>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<strong>{t('staffHelp.availability.note', 'Note:')}</strong>{' '}
|
||||
{t(
|
||||
'staffHelp.availability.noteDesc',
|
||||
'Time blocks you create will prevent new bookings during those times. Existing appointments are not affected.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tickets Section - Only if user has access */}
|
||||
{canAccessTickets && (
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Ticket size={20} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffHelp.tickets.title', 'Tickets')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.tickets.description',
|
||||
'You have access to the ticketing system. Use tickets to communicate with customers, report issues, or track requests.'
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature1', 'View and respond to tickets')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature2', 'Create new tickets for customer issues')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature3', 'Track ticket status and history')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Help Footer */}
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('staffHelp.footer.title', 'Need More Help?')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.footer.description',
|
||||
"If you have questions or need assistance, please contact your manager or supervisor."
|
||||
)}
|
||||
</p>
|
||||
{canAccessTickets && (
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('staffHelp.footer.openTicket', 'Open a Ticket')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffHelp;
|
||||
Reference in New Issue
Block a user