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:
poduck
2025-12-07 02:23:00 -05:00
parent 61882b300f
commit 01020861c7
48 changed files with 6156 additions and 148 deletions

View File

@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
// Import pages
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers'));
const Settings = React.lazy(() => import('./pages/Settings'));
@@ -97,6 +98,7 @@ const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
@@ -667,9 +669,29 @@ const AppContent: React.FC = () => {
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
/>
{/* Staff Schedule - vertical timeline view */}
<Route
path="/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/help" element={<HelpComprehensive />} />
<Route
path="/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
) : (
<HelpComprehensive />
)
}
/>
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
@@ -752,7 +774,7 @@ const AppContent: React.FC = () => {
<Route
path="/customers"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
@@ -762,7 +784,7 @@ const AppContent: React.FC = () => {
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
@@ -772,7 +794,7 @@ const AppContent: React.FC = () => {
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />

View File

@@ -0,0 +1,325 @@
/**
* Resource Detail Modal
*
* Shows resource details including a map of the staff member's
* current location when they are en route or in progress.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
import { Resource } from '../types';
import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation';
import Portal from './Portal';
import {
X,
MapPin,
Navigation,
Clock,
User as UserIcon,
Activity,
Loader2,
AlertCircle,
} from 'lucide-react';
interface ResourceDetailModalProps {
resource: Resource;
onClose: () => void;
}
const mapContainerStyle = {
width: '100%',
height: '300px',
borderRadius: '0.5rem',
};
const defaultCenter = {
lat: 39.8283, // Center of US
lng: -98.5795,
};
const ResourceDetailModal: React.FC<ResourceDetailModalProps> = ({ resource, onClose }) => {
const { t } = useTranslation();
const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
const hasApiKey = googleMapsApiKey.length > 0;
// Fetch location data
const { data: location, isLoading, error } = useResourceLocation(resource.id);
// Connect to live updates when tracking is active
useLiveResourceLocation(resource.id, {
enabled: location?.isTracking === true,
});
// Load Google Maps API only if we have a key
// When no API key, we skip the hook entirely to avoid warnings
const shouldLoadMaps = hasApiKey;
const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({
googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING',
});
// Treat missing API key as if maps failed to load
const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded;
const effectiveMapsError = !shouldLoadMaps || mapsLoadError;
// Map center based on location
const mapCenter = useMemo(() => {
if (location?.hasLocation && location.latitude && location.longitude) {
return { lat: location.latitude, lng: location.longitude };
}
return defaultCenter;
}, [location]);
// Format timestamp
const formattedTimestamp = useMemo(() => {
if (!location?.timestamp) return null;
const date = new Date(location.timestamp);
return date.toLocaleString();
}, [location?.timestamp]);
// Status color based on job status
const statusColor = useMemo(() => {
if (!location?.activeJob) return 'gray';
switch (location.activeJob.status) {
case 'EN_ROUTE':
return 'yellow';
case 'IN_PROGRESS':
return 'blue';
default:
return 'gray';
}
}, [location?.activeJob]);
return (
<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-2xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<UserIcon size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{resource.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.staffMember', 'Staff Member')}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span className="sr-only">{t('common.close')}</span>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Active Job Status */}
{location?.activeJob && (
<div className={`p-4 rounded-lg border ${
statusColor === 'yellow'
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: statusColor === 'blue'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
}`}>
<div className="flex items-center gap-3">
<Activity size={20} className={
statusColor === 'yellow'
? 'text-yellow-600 dark:text-yellow-400'
: statusColor === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
} />
<div>
<div className="font-medium text-gray-900 dark:text-white">
{location.activeJob.statusDisplay}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
{location.activeJob.title}
</div>
</div>
{location.isTracking && (
<span className="ml-auto inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
{t('resources.liveTracking', 'Live')}
</span>
)}
</div>
</div>
)}
{/* Map Section */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<MapPin size={16} />
{t('resources.currentLocation', 'Current Location')}
</h4>
{isLoading ? (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<Loader2 size={32} className="text-gray-400 animate-spin" />
</div>
) : error ? (
<div className="h-[300px] bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<AlertCircle size={32} className="text-red-400 mx-auto mb-2" />
<p className="text-red-600 dark:text-red-400">{t('resources.locationError', 'Failed to load location')}</p>
</div>
</div>
) : !location?.hasLocation ? (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<div className="text-center">
<MapPin size={32} className="text-gray-400 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">
{location?.message || t('resources.noLocationData', 'No location data available')}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{t('resources.locationHint', 'Location will appear when staff is en route')}
</p>
</div>
</div>
) : effectiveMapsError ? (
// Fallback when Google Maps isn't available - show coordinates
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg p-6">
<div className="h-full flex flex-col items-center justify-center">
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
<Navigation size={32} className="text-blue-600 dark:text-blue-400" />
</div>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{t('resources.gpsCoordinates', 'GPS Coordinates')}
</p>
<p className="text-lg font-medium text-gray-900 dark:text-white mb-1">
{location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)}
</p>
{location.speed !== undefined && location.speed !== null && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph
</p>
)}
{location.heading !== undefined && location.heading !== null && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}°
</p>
)}
</div>
<a
href={`https://www.google.com/maps?q=${location.latitude},${location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<MapPin size={16} />
{t('resources.openInMaps', 'Open in Google Maps')}
</a>
</div>
</div>
) : effectiveMapsLoaded ? (
<GoogleMap
mapContainerStyle={mapContainerStyle}
center={mapCenter}
zoom={15}
options={{
disableDefaultUI: false,
zoomControl: true,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: true,
}}
>
{location.latitude && location.longitude && (
<Marker
position={{ lat: location.latitude, lng: location.longitude }}
title={resource.name}
icon={{
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: location.isTracking ? '#22c55e' : '#3b82f6',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 3,
}}
/>
)}
</GoogleMap>
) : (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<Loader2 size={32} className="text-gray-400 animate-spin" />
</div>
)}
</div>
{/* Location Details */}
{location?.hasLocation && (
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
<Clock size={12} />
{t('resources.lastUpdate', 'Last Update')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{formattedTimestamp || '-'}
</div>
</div>
{location.accuracy && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.accuracy', 'Accuracy')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{location.accuracy < 1000
? `${location.accuracy.toFixed(0)}m`
: `${(location.accuracy / 1000).toFixed(1)}km`}
</div>
</div>
)}
{location.speed !== undefined && location.speed !== null && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.speed', 'Speed')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{(location.speed * 2.237).toFixed(1)} mph
</div>
</div>
)}
{location.heading !== undefined && location.heading !== null && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.heading', 'Heading')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{location.heading.toFixed(0)}°
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={onClose}
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 transition-colors"
>
{t('common.close', 'Close')}
</button>
</div>
</div>
</div>
</Portal>
);
};
export default ResourceDetailModal;

View File

@@ -4,7 +4,9 @@ import { CSS } from '@dnd-kit/utilities';
import { clsx } from 'clsx';
import { Clock, DollarSign } from 'lucide-react';
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
// Import from types.ts for consistency
import type { AppointmentStatus } from '../../types';
export type { AppointmentStatus };
export interface DraggableEventProps {
id: number;

View File

@@ -42,7 +42,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures();
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewManagementPages = role === 'owner' || role === 'manager';
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
@@ -110,19 +111,31 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
exact
/>
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
/>
{!isStaff && (
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
)}
{!isStaff && (
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
/>
)}
{isStaff && (
<SidebarItem
to="/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
/>
)}
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/my-availability"

View File

@@ -0,0 +1,186 @@
/**
* Resource Location Hook
*
* Fetches the latest location for a resource's linked staff member.
* Used for tracking staff during en route jobs.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useCallback } from 'react';
import apiClient from '../api/client';
export interface ResourceLocation {
hasLocation: boolean;
latitude?: number;
longitude?: number;
accuracy?: number;
heading?: number;
speed?: number;
timestamp?: string;
isTracking: boolean;
activeJob?: {
id: number;
title: string;
status: string;
statusDisplay: string;
} | null;
message?: string;
}
interface BackendLocationResponse {
has_location: boolean;
latitude?: number;
longitude?: number;
accuracy?: number;
heading?: number;
speed?: number;
timestamp?: string;
is_tracking?: boolean;
active_job?: {
id: number;
title: string;
status: string;
status_display: string;
} | null;
message?: string;
}
/**
* Hook to fetch a resource's latest location
*/
export const useResourceLocation = (resourceId: string | null, options?: { enabled?: boolean }) => {
return useQuery<ResourceLocation>({
queryKey: ['resourceLocation', resourceId],
queryFn: async () => {
const { data } = await apiClient.get<BackendLocationResponse>(`/resources/${resourceId}/location/`);
return {
hasLocation: data.has_location,
latitude: data.latitude,
longitude: data.longitude,
accuracy: data.accuracy,
heading: data.heading,
speed: data.speed,
timestamp: data.timestamp,
isTracking: data.is_tracking ?? false,
activeJob: data.active_job ? {
id: data.active_job.id,
title: data.active_job.title,
status: data.active_job.status,
statusDisplay: data.active_job.status_display,
} : null,
message: data.message,
};
},
enabled: !!resourceId && (options?.enabled !== false),
refetchInterval: false, // We'll use WebSocket for live updates instead
staleTime: 30000, // Consider data stale after 30 seconds
});
};
/**
* Hook for live location updates via WebSocket
*
* Connects to WebSocket when enabled and updates the query cache
* when new location data arrives.
*/
export const useLiveResourceLocation = (
resourceId: string | null,
options?: { enabled?: boolean }
) => {
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (!resourceId || options?.enabled === false) return;
// Get WebSocket URL from current host
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.hostname;
const port = '8000'; // Backend port
const wsUrl = `${protocol}//${host}:${port}/ws/resource-location/${resourceId}/`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[useLiveResourceLocation] WebSocket connected for resource:', resourceId);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'location_update') {
// Update the query cache with new location data
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
...old,
hasLocation: true,
latitude: data.latitude,
longitude: data.longitude,
accuracy: data.accuracy,
heading: data.heading,
speed: data.speed,
timestamp: data.timestamp,
isTracking: true,
activeJob: data.active_job ? {
id: data.active_job.id,
title: data.active_job.title,
status: data.active_job.status,
statusDisplay: data.active_job.status_display,
} : old?.activeJob ?? null,
}));
} else if (data.type === 'tracking_stopped') {
// Staff stopped tracking
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
...old,
hasLocation: old?.hasLocation ?? false,
isTracking: false,
}));
}
} catch (err) {
console.error('[useLiveResourceLocation] Failed to parse message:', err);
}
};
ws.onerror = (error) => {
console.error('[useLiveResourceLocation] WebSocket error:', error);
};
ws.onclose = (event) => {
console.log('[useLiveResourceLocation] WebSocket closed:', event.code, event.reason);
wsRef.current = null;
// Reconnect after 5 seconds if not a clean close
if (event.code !== 1000 && options?.enabled !== false) {
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 5000);
}
};
} catch (err) {
console.error('[useLiveResourceLocation] Failed to connect:', err);
}
}, [resourceId, options?.enabled, queryClient]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounting');
wsRef.current = null;
}
};
}, [connect]);
// Return a function to manually refresh
return {
refresh: () => queryClient.invalidateQueries({ queryKey: ['resourceLocation', resourceId] }),
};
};

View File

@@ -30,6 +30,7 @@ export const useResources = (filters?: ResourceFilters) => {
userId: r.user_id ? String(r.user_id) : undefined,
maxConcurrentEvents: r.max_concurrent_events ?? 1,
savedLaneCount: r.saved_lane_count,
userCanEditSchedule: r.user_can_edit_schedule ?? false,
}));
},
});
@@ -51,6 +52,7 @@ export const useResource = (id: string) => {
userId: data.user_id ? String(data.user_id) : undefined,
maxConcurrentEvents: data.max_concurrent_events ?? 1,
savedLaneCount: data.saved_lane_count,
userCanEditSchedule: data.user_can_edit_schedule ?? false,
};
},
enabled: !!id,
@@ -65,13 +67,23 @@ export const useCreateResource = () => {
return useMutation({
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
const backendData = {
const backendData: any = {
name: resourceData.name,
type: resourceData.type,
user: resourceData.userId ? parseInt(resourceData.userId) : null,
timezone: 'UTC', // Default timezone
};
if (resourceData.maxConcurrentEvents !== undefined) {
backendData.max_concurrent_events = resourceData.maxConcurrentEvents;
}
if (resourceData.savedLaneCount !== undefined) {
backendData.saved_lane_count = resourceData.savedLaneCount;
}
if (resourceData.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = resourceData.userCanEditSchedule;
}
const { data } = await apiClient.post('/resources/', backendData);
return data;
},
@@ -101,6 +113,9 @@ export const useUpdateResource = () => {
if (updates.savedLaneCount !== undefined) {
backendData.saved_lane_count = updates.savedLaneCount;
}
if (updates.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = updates.userCanEditSchedule;
}
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
return data;

View File

@@ -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>
);
};

View 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;

View 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;

View File

@@ -124,6 +124,8 @@ export interface User {
notification_preferences?: NotificationPreferences;
can_invite_staff?: boolean;
can_access_tickets?: boolean;
can_edit_schedule?: boolean;
linked_resource_id?: number;
permissions?: Record<string, boolean>;
quota_overages?: QuotaOverage[];
}