feat: Add time block approval workflow and staff permission system
- Add TimeBlock approval status with manager approval workflow - Create core mixins for staff permission restrictions (DenyStaffWritePermission, etc.) - Add StaffDashboard page for staff-specific views - Refactor MyAvailability page for time block management - Update field mobile status machine and views - Add per-user permission overrides via JSONField - Document core mixins and permission system in CLAUDE.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
||||
|
||||
// Import pages
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||
@@ -667,7 +668,7 @@ const AppContent: React.FC = () => {
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||
/>
|
||||
{/* Staff Schedule - vertical timeline view */}
|
||||
<Route
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface User {
|
||||
permissions?: Record<string, boolean>;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
linked_resource_id?: number;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_self_approve_time_off',
|
||||
labelKey: 'staff.canSelfApproveTimeOff',
|
||||
labelDefault: 'Can self-approve time off',
|
||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
|
||||
@@ -155,6 +155,10 @@ interface TimeBlockCreatorModalProps {
|
||||
holidays: Holiday[];
|
||||
resources: Resource[];
|
||||
isResourceLevel?: boolean;
|
||||
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
|
||||
staffMode?: boolean;
|
||||
/** Pre-selected resource ID for staff mode */
|
||||
staffResourceId?: string | number | null;
|
||||
}
|
||||
|
||||
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
||||
@@ -168,6 +172,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
holidays,
|
||||
resources,
|
||||
isResourceLevel: initialIsResourceLevel = false,
|
||||
staffMode = false,
|
||||
staffResourceId = null,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
||||
@@ -177,7 +183,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
// Form state
|
||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||
const [description, setDescription] = useState(editingBlock?.description || '');
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
|
||||
// In staff mode, default to SOFT blocks (time-off requests)
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
|
||||
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
|
||||
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
||||
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
||||
@@ -270,7 +277,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setAllDay(true);
|
||||
setStartTime('09:00');
|
||||
setEndTime('17:00');
|
||||
setResourceId(null);
|
||||
// In staff mode, pre-select the staff's resource
|
||||
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
|
||||
setSelectedDates([]);
|
||||
setDaysOfWeek([]);
|
||||
setDaysOfMonth([]);
|
||||
@@ -279,10 +287,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setHolidayCodes([]);
|
||||
setRecurrenceStart('');
|
||||
setRecurrenceEnd('');
|
||||
setIsResourceLevel(initialIsResourceLevel);
|
||||
// In staff mode, always resource-level
|
||||
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel]);
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||
|
||||
// Apply preset configuration
|
||||
const applyPreset = (presetId: string) => {
|
||||
@@ -293,7 +302,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setTitle(preset.config.title);
|
||||
setRecurrenceType(preset.config.recurrence_type);
|
||||
setAllDay(preset.config.all_day);
|
||||
setBlockType(preset.config.block_type);
|
||||
// In staff mode, always use SOFT blocks regardless of preset
|
||||
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
|
||||
|
||||
if (preset.config.start_time) setStartTime(preset.config.start_time);
|
||||
if (preset.config.end_time) setEndTime(preset.config.end_time);
|
||||
@@ -367,12 +377,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In staff mode, always use the staff's resource ID
|
||||
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||
|
||||
const baseData: any = {
|
||||
description: description || undefined,
|
||||
block_type: blockType,
|
||||
recurrence_type: recurrenceType,
|
||||
all_day: allDay,
|
||||
resource: isResourceLevel ? resourceId : null,
|
||||
resource: isResourceLevel ? effectiveResourceId : null,
|
||||
};
|
||||
|
||||
if (!allDay) {
|
||||
@@ -425,7 +438,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
return true;
|
||||
case 'details':
|
||||
if (!title.trim()) return false;
|
||||
if (isResourceLevel && !resourceId) return false;
|
||||
// In staff mode, resource is auto-selected; otherwise check if selected
|
||||
if (isResourceLevel && !staffMode && !resourceId) return false;
|
||||
return true;
|
||||
case 'schedule':
|
||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||
@@ -556,63 +570,65 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
{/* Step 2: Details */}
|
||||
{step === 'details' && (
|
||||
<div className="space-y-6">
|
||||
{/* Block Level Selector */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
!isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
{/* Block Level Selector - Hidden in staff mode */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
!isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Business-wide
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects all resources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Business-wide
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects all resources
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResourceLevel(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Resource
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects one resource
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResourceLevel(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Resource
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects one resource
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
@@ -642,8 +658,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource (if resource-level) */}
|
||||
{isResourceLevel && (
|
||||
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||
{isResourceLevel && !staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
@@ -661,52 +677,54 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block Type */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('HARD')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'HARD'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('HARD')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'HARD'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completely prevents bookings. Cannot be overridden.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('SOFT')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'SOFT'
|
||||
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completely prevents bookings. Cannot be overridden.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('SOFT')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'SOFT'
|
||||
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows a warning but allows bookings with override.
|
||||
</p>
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows a warning but allows bookings with override.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle & Time */}
|
||||
<div>
|
||||
@@ -1188,11 +1206,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{isResourceLevel && resourceId && (
|
||||
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||
<div className="flex justify-between py-2">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
{resources.find(r => r.id === resourceId)?.name || resourceId}
|
||||
{resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -158,8 +158,9 @@ export const useMyBlocks = () => {
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
resource_id: String(data.resource_id),
|
||||
resource_id: data.resource_id ? String(data.resource_id) : null,
|
||||
resource_name: data.resource_name,
|
||||
can_self_approve: data.can_self_approve,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -248,6 +249,75 @@ export const useToggleTimeBlock = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Time Block Approval Hooks
|
||||
// =============================================================================
|
||||
|
||||
export interface PendingReviewsResponse {
|
||||
count: number;
|
||||
pending_blocks: TimeBlockListItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch pending time block reviews (for managers/owners)
|
||||
*/
|
||||
export const usePendingReviews = () => {
|
||||
return useQuery<PendingReviewsResponse>({
|
||||
queryKey: ['time-block-pending-reviews'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/time-blocks/pending_reviews/');
|
||||
return {
|
||||
count: data.count,
|
||||
pending_blocks: data.pending_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to approve a time block
|
||||
*/
|
||||
export const useApproveTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/approve/`, { notes });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to deny a time block
|
||||
*/
|
||||
export const useDenyTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/deny/`, { notes });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for conflicts before creating a time block
|
||||
*/
|
||||
|
||||
@@ -491,7 +491,39 @@
|
||||
"reactivateAccount": "Reactivate Account",
|
||||
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
||||
"reactivateHint": "Allow this user to log in again",
|
||||
"deactivate": "Deactivate"
|
||||
"deactivate": "Deactivate",
|
||||
"canSelfApproveTimeOff": "Can self-approve time off",
|
||||
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval"
|
||||
},
|
||||
"staffDashboard": {
|
||||
"welcomeTitle": "Welcome, {{name}}!",
|
||||
"weekOverview": "Here's your week at a glance",
|
||||
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
|
||||
"currentAppointment": "Current Appointment",
|
||||
"nextAppointment": "Next Appointment",
|
||||
"viewSchedule": "View Schedule",
|
||||
"todayAppointments": "Today",
|
||||
"thisWeek": "This Week",
|
||||
"completed": "Completed",
|
||||
"hoursWorked": "Hours Worked",
|
||||
"appointmentsLabel": "appointments",
|
||||
"totalAppointments": "total appointments",
|
||||
"completionRate": "completion rate",
|
||||
"thisWeekLabel": "this week",
|
||||
"upcomingAppointments": "Upcoming",
|
||||
"noUpcoming": "No upcoming appointments",
|
||||
"weeklyOverview": "This Week",
|
||||
"appointments": "Appointments",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"scheduled": "Scheduled",
|
||||
"inProgress": "In Progress",
|
||||
"cancelled": "Cancelled",
|
||||
"noShows": "No-Shows",
|
||||
"viewMySchedule": "View My Schedule",
|
||||
"viewScheduleDesc": "See your daily appointments and manage your time",
|
||||
"manageAvailability": "Manage Availability",
|
||||
"availabilityDesc": "Set your working hours and time off"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Support Tickets",
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* My Availability Page
|
||||
*
|
||||
* Staff-facing page to view and manage their own time blocks.
|
||||
* Uses the same UI as TimeBlocks but locked to the staff's own resource.
|
||||
* Shows business-level blocks (read-only) and personal blocks (editable).
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
TimeBlockListItem,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
User,
|
||||
} from '../types';
|
||||
import {
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
CreateTimeBlockData,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import Portal from '../components/Portal';
|
||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
|
||||
import {
|
||||
Calendar,
|
||||
Building2,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
@@ -42,8 +41,13 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HourglassIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type AvailabilityTab = 'blocks' | 'calendar';
|
||||
|
||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||
NONE: 'One-time',
|
||||
WEEKLY: 'Weekly',
|
||||
@@ -57,43 +61,6 @@ const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
SOFT: 'Soft Block',
|
||||
};
|
||||
|
||||
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
interface TimeBlockFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
all_day: boolean;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
recurrence_pattern: RecurrencePattern;
|
||||
recurrence_start: string;
|
||||
recurrence_end: string;
|
||||
}
|
||||
|
||||
const defaultFormData: TimeBlockFormData = {
|
||||
title: '',
|
||||
description: '',
|
||||
block_type: 'SOFT',
|
||||
recurrence_type: 'NONE',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
};
|
||||
|
||||
interface MyAvailabilityProps {
|
||||
user?: User;
|
||||
}
|
||||
@@ -103,9 +70,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
||||
const user = props.user || contextUser;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<AvailabilityTab>('blocks');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Fetch data
|
||||
@@ -118,105 +85,20 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
|
||||
// Check if user can create hard blocks
|
||||
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
|
||||
|
||||
// Modal handlers
|
||||
const openCreateModal = () => {
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (block: TimeBlockListItem) => {
|
||||
setEditingBlock(block);
|
||||
setFormData({
|
||||
title: block.title,
|
||||
description: '',
|
||||
block_type: block.block_type,
|
||||
recurrence_type: block.recurrence_type,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
};
|
||||
|
||||
// Form handlers
|
||||
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDayOfWeekToggle = (day: number) => {
|
||||
const current = formData.recurrence_pattern.days_of_week || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort();
|
||||
handlePatternChange('days_of_week', newDays);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!myBlocksData?.resource_id) {
|
||||
console.error('No resource linked to user');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateTimeBlockData = {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
resource: myBlocksData.resource_id,
|
||||
block_type: formData.block_type,
|
||||
recurrence_type: formData.recurrence_type,
|
||||
all_day: formData.all_day,
|
||||
};
|
||||
|
||||
// Add type-specific fields
|
||||
if (formData.recurrence_type === 'NONE') {
|
||||
payload.start_date = formData.start_date;
|
||||
payload.end_date = formData.end_date || formData.start_date;
|
||||
}
|
||||
|
||||
if (!formData.all_day) {
|
||||
payload.start_time = formData.start_time;
|
||||
payload.end_time = formData.end_time;
|
||||
}
|
||||
|
||||
if (formData.recurrence_type !== 'NONE') {
|
||||
payload.recurrence_pattern = formData.recurrence_pattern;
|
||||
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
|
||||
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
|
||||
} else {
|
||||
await createBlock.mutateAsync(payload);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -264,6 +146,35 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</span>
|
||||
);
|
||||
|
||||
// Render approval status badge
|
||||
const renderApprovalBadge = (status: string | undefined) => {
|
||||
if (!status || status === 'APPROVED') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle size={12} className="mr-1" />
|
||||
{t('myAvailability.approved', 'Approved')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'PENDING') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<HourglassIcon size={12} className="mr-1" />
|
||||
{t('myAvailability.pending', 'Pending Review')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'DENIED') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<XCircle size={12} className="mr-1" />
|
||||
{t('myAvailability.denied', 'Denied')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle no linked resource
|
||||
if (!isLoading && !myBlocksData?.resource_id) {
|
||||
return (
|
||||
@@ -290,6 +201,12 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Create a mock resource for the modal
|
||||
const staffResource = myBlocksData?.resource_id ? {
|
||||
id: myBlocksData.resource_id,
|
||||
name: myBlocksData.resource_name || 'My Resource',
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
@@ -299,447 +216,271 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
{t('myAvailability.title', 'My Availability')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{myBlocksData?.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon size={14} />
|
||||
{myBlocksData.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addBlock', 'Block Time')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Approval Required Banner */}
|
||||
{myBlocksData?.can_self_approve === false && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-100">
|
||||
{t('myAvailability.approvalRequired', 'Approval Required')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t('myAvailability.approvalRequiredInfo', 'Your time off requests require manager or owner approval. New blocks will show as "Pending Review" until approved.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Blocks Info Banner */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 size={20} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone:')}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{myBlocksData.business_blocks.map((block) => (
|
||||
<span
|
||||
key={block.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-800/50 text-blue-800 dark:text-blue-200 rounded text-sm"
|
||||
>
|
||||
{block.title}
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Availability tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('blocks')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'blocks'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<UserIcon size={18} />
|
||||
{t('myAvailability.myBlocksTab', 'My Time Blocks')}
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length > 0 && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{myBlocksData.my_blocks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'calendar'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
{t('myAvailability.calendarTab', 'Yearly View')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Business Blocks (Read-only) */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
|
||||
<Info size={16} />
|
||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
|
||||
<div className="space-y-4">
|
||||
{activeTab === 'blocks' && (
|
||||
<>
|
||||
{/* Resource Info Banner */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-300 flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
{t('myAvailability.resourceInfo', 'Managing blocks for:')}
|
||||
<span className="font-semibold">{myBlocksData?.resource_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData.business_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* My Blocks List */}
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('myAvailability.noBlocks', 'No Time Blocks')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.titleCol', 'Title')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.typeCol', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.patternCol', 'Pattern')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.statusCol', 'Status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.actionsCol', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData?.my_blocks.map((block) => (
|
||||
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
{!block.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderApprovalBadge((block as any).approval_status)}
|
||||
{(block as any).review_notes && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
"{(block as any).review_notes}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(block)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* My Blocks (Editable) */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={20} />
|
||||
{t('myAvailability.myBlocks', 'My Time Blocks')}
|
||||
</h2>
|
||||
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('myAvailability.noBlocks', 'No Time Blocks')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
||||
</p>
|
||||
<button onClick={openCreateModal} className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.titleCol', 'Title')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.typeCol', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.patternCol', 'Pattern')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.actionsCol', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData?.my_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(block)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yearly Calendar View */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<YearlyBlockCalendar
|
||||
resourceId={myBlocksData?.resource_id}
|
||||
onBlockClick={(blockId) => {
|
||||
// Find the block and open edit modal if it's my block
|
||||
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
|
||||
if (block) {
|
||||
openEditModal(block);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{activeTab === 'calendar' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<YearlyBlockCalendar
|
||||
resourceId={myBlocksData?.resource_id}
|
||||
onBlockClick={(blockId) => {
|
||||
// Find the block and open edit modal if it's my block
|
||||
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
|
||||
if (block) {
|
||||
openEditModal(block);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{editingBlock
|
||||
? t('myAvailability.editBlock', 'Edit Time Block')
|
||||
: t('myAvailability.createBlock', 'Block Time Off')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.title', 'Title')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
placeholder="e.g., Vacation, Lunch Break, Doctor Appointment"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.description', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
rows={2}
|
||||
placeholder="Optional reason"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Block Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.blockType', 'Block Type')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="SOFT"
|
||||
checked={formData.block_type === 'SOFT'}
|
||||
onChange={() => handleFormChange('block_type', 'SOFT')}
|
||||
className="text-brand-500"
|
||||
/>
|
||||
<AlertCircle size={16} className="text-yellow-500" />
|
||||
<span className="text-sm">Soft Block</span>
|
||||
<span className="text-xs text-gray-500">(shows warning, can be overridden)</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 ${canCreateHardBlocks ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="HARD"
|
||||
checked={formData.block_type === 'HARD'}
|
||||
onChange={() => canCreateHardBlocks && handleFormChange('block_type', 'HARD')}
|
||||
className="text-brand-500"
|
||||
disabled={!canCreateHardBlocks}
|
||||
/>
|
||||
<Ban size={16} className="text-red-500" />
|
||||
<span className="text-sm">Hard Block</span>
|
||||
<span className="text-xs text-gray-500">(prevents booking)</span>
|
||||
{!canCreateHardBlocks && (
|
||||
<span className="text-xs text-red-500">(requires permission)</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.recurrenceType', 'Recurrence')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.recurrence_type}
|
||||
onChange={(e) => handleFormChange('recurrence_type', e.target.value as RecurrenceType)}
|
||||
className="input-primary w-full"
|
||||
>
|
||||
<option value="NONE">One-time (specific date/range)</option>
|
||||
<option value="WEEKLY">Weekly (e.g., every Monday)</option>
|
||||
<option value="MONTHLY">Monthly (e.g., 1st of month)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Pattern - NONE */}
|
||||
{formData.recurrence_type === 'NONE' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => handleFormChange('start_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => handleFormChange('end_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
min={formData.start_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - WEEKLY */}
|
||||
{formData.recurrence_type === 'WEEKLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Week *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAY_ABBREVS.map((day, index) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_week || []).includes(index);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => handleDayOfWeekToggle(index)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - MONTHLY */}
|
||||
{formData.recurrence_type === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Month *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_month || []).includes(day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = formData.recurrence_pattern.days_of_month || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort((a, b) => a - b);
|
||||
handlePatternChange('days_of_month', newDays);
|
||||
}}
|
||||
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="all_day"
|
||||
checked={formData.all_day}
|
||||
onChange={(e) => handleFormChange('all_day', e.target.checked)}
|
||||
className="rounded text-brand-500"
|
||||
/>
|
||||
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('myAvailability.form.allDay', 'All day')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Time Range (if not all day) */}
|
||||
{!formData.all_day && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.start_time}
|
||||
onChange={(e) => handleFormChange('start_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.end_time}
|
||||
onChange={(e) => handleFormChange('end_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={closeModal} className="btn-secondary">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={createBlock.isPending || updateBlock.isPending}
|
||||
>
|
||||
{(createBlock.isPending || updateBlock.isPending) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.saving', 'Saving...')}
|
||||
</span>
|
||||
) : editingBlock ? (
|
||||
t('common.save', 'Save Changes')
|
||||
) : (
|
||||
t('myAvailability.create', 'Block Time')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
{/* Create/Edit Modal - Using TimeBlockCreatorModal in staff mode */}
|
||||
<TimeBlockCreatorModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
|
||||
} else {
|
||||
// Handle array of blocks (multiple holidays)
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
for (const block of blocks) {
|
||||
await createBlock.mutateAsync(block);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
}}
|
||||
isSubmitting={createBlock.isPending || updateBlock.isPending}
|
||||
editingBlock={editingBlock}
|
||||
holidays={holidays}
|
||||
resources={staffResource ? [staffResource as any] : []}
|
||||
isResourceLevel={true}
|
||||
staffMode={true}
|
||||
staffResourceId={myBlocksData?.resource_id}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
@@ -760,12 +501,15 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="btn-danger"
|
||||
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
disabled={deleteBlock.isPending}
|
||||
>
|
||||
{deleteBlock.isPending ? (
|
||||
|
||||
627
frontend/src/pages/StaffDashboard.tsx
Normal file
627
frontend/src/pages/StaffDashboard.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
format,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isWithinInterval,
|
||||
parseISO,
|
||||
differenceInMinutes,
|
||||
isBefore,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
CalendarOff,
|
||||
ArrowRight,
|
||||
PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../api/client';
|
||||
import { User as UserType } from '../types';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface StaffDashboardProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
customer_name?: string;
|
||||
service_name?: string;
|
||||
}
|
||||
|
||||
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
|
||||
// Fetch this week's appointments for statistics
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
const { data: weekAppointments = [], isLoading } = useQuery({
|
||||
queryKey: ['staff-week-appointments', userResourceId, format(weekStart, 'yyyy-MM-dd')],
|
||||
queryFn: async () => {
|
||||
if (!userResourceId) return [];
|
||||
|
||||
const response = await apiClient.get('/appointments/', {
|
||||
params: {
|
||||
resource: userResourceId,
|
||||
start_date: weekStart.toISOString(),
|
||||
end_date: weekEnd.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const todayStart = startOfDay(now);
|
||||
const todayEnd = endOfDay(now);
|
||||
|
||||
const todayAppointments = weekAppointments.filter((apt) =>
|
||||
isWithinInterval(parseISO(apt.start_time), { start: todayStart, end: todayEnd })
|
||||
);
|
||||
|
||||
const completed = weekAppointments.filter(
|
||||
(apt) => apt.status === 'COMPLETED' || apt.status === 'PAID'
|
||||
).length;
|
||||
|
||||
const cancelled = weekAppointments.filter(
|
||||
(apt) => apt.status === 'CANCELLED' || apt.status === 'CANCELED'
|
||||
).length;
|
||||
|
||||
const noShows = weekAppointments.filter(
|
||||
(apt) => apt.status === 'NOSHOW' || apt.status === 'NO_SHOW'
|
||||
).length;
|
||||
|
||||
const scheduled = weekAppointments.filter(
|
||||
(apt) =>
|
||||
apt.status === 'SCHEDULED' ||
|
||||
apt.status === 'CONFIRMED' ||
|
||||
apt.status === 'PENDING'
|
||||
).length;
|
||||
|
||||
const inProgress = weekAppointments.filter(
|
||||
(apt) => apt.status === 'IN_PROGRESS'
|
||||
).length;
|
||||
|
||||
// Calculate total hours worked this week
|
||||
const totalMinutes = weekAppointments
|
||||
.filter((apt) => apt.status === 'COMPLETED' || apt.status === 'PAID')
|
||||
.reduce((acc, apt) => {
|
||||
const start = parseISO(apt.start_time);
|
||||
const end = parseISO(apt.end_time);
|
||||
return acc + differenceInMinutes(end, start);
|
||||
}, 0);
|
||||
|
||||
const hoursWorked = Math.round(totalMinutes / 60 * 10) / 10;
|
||||
|
||||
return {
|
||||
todayCount: todayAppointments.length,
|
||||
weekTotal: weekAppointments.length,
|
||||
completed,
|
||||
cancelled,
|
||||
noShows,
|
||||
scheduled,
|
||||
inProgress,
|
||||
hoursWorked,
|
||||
completionRate: weekAppointments.length > 0
|
||||
? Math.round((completed / weekAppointments.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Get current or next appointment
|
||||
const currentOrNextAppointment = useMemo(() => {
|
||||
const now = new Date();
|
||||
|
||||
// First check for in-progress
|
||||
const inProgress = weekAppointments.find((apt) => apt.status === 'IN_PROGRESS');
|
||||
if (inProgress) {
|
||||
return { type: 'current', appointment: inProgress };
|
||||
}
|
||||
|
||||
// Find next upcoming appointment
|
||||
const upcoming = weekAppointments
|
||||
.filter(
|
||||
(apt) =>
|
||||
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||
isAfter(parseISO(apt.start_time), now)
|
||||
)
|
||||
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime());
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
return { type: 'next', appointment: upcoming[0] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Get upcoming appointments (next 3 days)
|
||||
const upcomingAppointments = useMemo(() => {
|
||||
const now = new Date();
|
||||
const threeDaysLater = addDays(now, 3);
|
||||
|
||||
return weekAppointments
|
||||
.filter(
|
||||
(apt) =>
|
||||
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||
isAfter(parseISO(apt.start_time), now) &&
|
||||
isBefore(parseISO(apt.start_time), threeDaysLater)
|
||||
)
|
||||
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime())
|
||||
.slice(0, 5);
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Weekly chart data
|
||||
const weeklyChartData = useMemo(() => {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const dayMap: Record<string, number> = {};
|
||||
|
||||
days.forEach((day) => {
|
||||
dayMap[day] = 0;
|
||||
});
|
||||
|
||||
weekAppointments.forEach((apt) => {
|
||||
const date = parseISO(apt.start_time);
|
||||
const dayIndex = (date.getDay() + 6) % 7; // Convert to Mon=0, Sun=6
|
||||
const dayName = days[dayIndex];
|
||||
dayMap[dayName]++;
|
||||
});
|
||||
|
||||
return days.map((day) => ({ name: day, appointments: dayMap[day] }));
|
||||
}, [weekAppointments]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'SCHEDULED':
|
||||
case 'CONFIRMED':
|
||||
case 'PENDING':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
case 'COMPLETED':
|
||||
case 'PAID':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'CANCELLED':
|
||||
case 'CANCELED':
|
||||
case 'NOSHOW':
|
||||
case 'NO_SHOW':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const formatAppointmentDate = (dateStr: string) => {
|
||||
const date = parseISO(dateStr);
|
||||
if (isToday(date)) return t('staffDashboard.today', 'Today');
|
||||
if (isTomorrow(date)) return t('staffDashboard.tomorrow', 'Tomorrow');
|
||||
return format(date, 'EEE, MMM d');
|
||||
};
|
||||
|
||||
// Show message if no resource is linked
|
||||
if (!userResourceId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<Calendar size={64} className="mx-auto text-gray-300 dark:text-gray-600 mb-6" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t(
|
||||
'staffDashboard.noResourceLinked',
|
||||
'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 animate-pulse"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 bg-gray-50 dark:bg-gray-900 min-h-full">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.weekOverview', "Here's your week at a glance")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current/Next Appointment Banner */}
|
||||
{currentOrNextAppointment && (
|
||||
<div
|
||||
className={`p-4 rounded-xl border-l-4 ${
|
||||
currentOrNextAppointment.type === 'current'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
currentOrNextAppointment.type === 'current'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-600'
|
||||
: 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{currentOrNextAppointment.type === 'current' ? (
|
||||
<PlayCircle size={24} />
|
||||
) : (
|
||||
<Clock size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{currentOrNextAppointment.type === 'current'
|
||||
? t('staffDashboard.currentAppointment', 'Current Appointment')
|
||||
: t('staffDashboard.nextAppointment', 'Next Appointment')}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{currentOrNextAppointment.appointment.service_name ||
|
||||
currentOrNextAppointment.appointment.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||
{currentOrNextAppointment.appointment.customer_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{currentOrNextAppointment.appointment.customer_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{format(parseISO(currentOrNextAppointment.appointment.start_time), 'h:mm a')} -{' '}
|
||||
{format(parseISO(currentOrNextAppointment.appointment.end_time), 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{t('staffDashboard.viewSchedule', 'View Schedule')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Today's Appointments */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.todayAppointments', 'Today')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.todayCount}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.appointmentsLabel', 'appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* This Week Total */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
|
||||
<CalendarDays size={18} className="text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.thisWeek', 'This Week')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.weekTotal}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.totalAppointments', 'total appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Completed */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg">
|
||||
<CheckCircle size={18} className="text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.completed', 'Completed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.completed}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.completionRate}% {t('staffDashboard.completionRate', 'completion rate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hours Worked */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-orange-100 dark:bg-orange-900/40 rounded-lg">
|
||||
<Clock size={18} className="text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.hoursWorked', 'Hours Worked')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.hoursWorked}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.thisWeekLabel', 'this week')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.upcomingAppointments', 'Upcoming')}
|
||||
</h2>
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{t('common.viewAll', 'View All')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Calendar size={40} className="mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.noUpcoming', 'No upcoming appointments')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingAppointments.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{apt.service_name || apt.title}
|
||||
</h4>
|
||||
{apt.customer_name && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-0.5">
|
||||
<User size={10} />
|
||||
{apt.customer_name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formatAppointmentDate(apt.start_time)} at{' '}
|
||||
{format(parseISO(apt.start_time), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getStatusColor(
|
||||
apt.status
|
||||
)}`}
|
||||
>
|
||||
{apt.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weekly Chart */}
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('staffDashboard.weeklyOverview', 'This Week')}
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={weeklyChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
||||
/>
|
||||
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Breakdown */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.scheduled', 'Scheduled')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900/40 rounded-lg">
|
||||
<TrendingUp size={18} className="text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.inProgress}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.inProgress', 'In Progress')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
||||
<XCircle size={18} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.cancelled}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.cancelled', 'Cancelled')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<User size={18} className="text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.noShows}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.noShows', 'No-Shows')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/40 rounded-lg group-hover:bg-brand-200 dark:group-hover:bg-brand-800/40 transition-colors">
|
||||
<CalendarDays size={24} className="text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.viewMySchedule', 'View My Schedule')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.viewScheduleDesc', 'See your daily appointments and manage your time')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={20} className="text-gray-400 group-hover:text-brand-500 ml-auto transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-availability"
|
||||
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900/40 rounded-lg group-hover:bg-green-200 dark:group-hover:bg-green-800/40 transition-colors">
|
||||
<CalendarOff size={24} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.manageAvailability', 'Manage Availability')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.availabilityDesc', 'Set your working hours and time off')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={20} className="text-gray-400 group-hover:text-green-500 ml-auto transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffDashboard;
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
usePendingReviews,
|
||||
useApproveTimeBlock,
|
||||
useDenyTimeBlock,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import Portal from '../components/Portal';
|
||||
@@ -38,6 +41,12 @@ import {
|
||||
AlertCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
HourglassIcon,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
||||
@@ -61,6 +70,9 @@ const TimeBlocks: React.FC = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [isPendingReviewsExpanded, setIsPendingReviewsExpanded] = useState(true);
|
||||
const [reviewingBlock, setReviewingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
|
||||
// Fetch data (include inactive blocks so users can re-enable them)
|
||||
const {
|
||||
@@ -75,12 +87,15 @@ const TimeBlocks: React.FC = () => {
|
||||
|
||||
const { data: holidays = [] } = useHolidays('US');
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: pendingReviews } = usePendingReviews();
|
||||
|
||||
// Mutations
|
||||
const createBlock = useCreateTimeBlock();
|
||||
const updateBlock = useUpdateTimeBlock();
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
const approveBlock = useApproveTimeBlock();
|
||||
const denyBlock = useDenyTimeBlock();
|
||||
|
||||
// Current blocks based on tab
|
||||
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
|
||||
@@ -130,6 +145,26 @@ const TimeBlocks: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await approveBlock.mutateAsync({ id, notes: reviewNotes });
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
} catch (error) {
|
||||
console.error('Failed to approve time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
try {
|
||||
await denyBlock.mutateAsync({ id, notes: reviewNotes });
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
} catch (error) {
|
||||
console.error('Failed to deny time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render block type badge
|
||||
const renderBlockTypeBadge = (type: BlockType) => (
|
||||
<span
|
||||
@@ -179,6 +214,97 @@ const TimeBlocks: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Reviews Section */}
|
||||
{pendingReviews && pendingReviews.count > 0 && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsPendingReviewsExpanded(!isPendingReviewsExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-800/50 rounded-lg">
|
||||
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
{t('timeBlocks.pendingReviews', 'Pending Time Off Requests')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t('timeBlocks.pendingReviewsCount', '{{count}} request(s) need your review', { count: pendingReviews.count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isPendingReviewsExpanded ? (
|
||||
<ChevronUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isPendingReviewsExpanded && (
|
||||
<div className="border-t border-amber-200 dark:border-amber-800">
|
||||
<div className="divide-y divide-amber-200 dark:divide-amber-800">
|
||||
{pendingReviews.pending_blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className="p-4 hover:bg-amber-100/50 dark:hover:bg-amber-900/30 cursor-pointer transition-colors"
|
||||
onClick={() => setReviewingBlock(block)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{block.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{block.created_by_name && (
|
||||
<span>Requested by {block.created_by_name}</span>
|
||||
)}
|
||||
{block.pattern_display && (
|
||||
<span>{block.pattern_display}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleApprove(block.id);
|
||||
}}
|
||||
className="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
|
||||
title={t('timeBlocks.approve', 'Approve')}
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setReviewingBlock(block);
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
title={t('timeBlocks.deny', 'Deny')}
|
||||
>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Time block tabs">
|
||||
@@ -548,6 +674,205 @@ const TimeBlocks: React.FC = () => {
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{reviewingBlock && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<HourglassIcon size={24} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.reviewRequest', 'Review Time Off Request')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('timeBlocks.reviewRequestDesc', 'Approve or deny this time off request')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block Details */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.titleCol', 'Title')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.title}</span>
|
||||
</div>
|
||||
{reviewingBlock.resource_name && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.resource', 'Resource')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.resource_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.typeCol', 'Type')}</span>
|
||||
{renderBlockTypeBadge(reviewingBlock.block_type)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Details Section */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
|
||||
{t('timeBlocks.scheduleDetails', 'Schedule Details')}
|
||||
</span>
|
||||
|
||||
{/* Recurrence Type */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.patternCol', 'Pattern')}</span>
|
||||
{renderRecurrenceBadge(reviewingBlock.recurrence_type)}
|
||||
</div>
|
||||
|
||||
{/* One-time block dates */}
|
||||
{reviewingBlock.recurrence_type === 'NONE' && reviewingBlock.start_date && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.dates', 'Date(s)')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.start_date === reviewingBlock.end_date ? (
|
||||
new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : (
|
||||
<>
|
||||
{new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
{' - '}
|
||||
{new Date((reviewingBlock.end_date || reviewingBlock.start_date) + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly: Days of week */}
|
||||
{reviewingBlock.recurrence_type === 'WEEKLY' && reviewingBlock.recurrence_pattern?.days_of_week && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfWeek', 'Days')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_pattern.days_of_week
|
||||
.map(d => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d])
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly: Days of month */}
|
||||
{reviewingBlock.recurrence_type === 'MONTHLY' && reviewingBlock.recurrence_pattern?.days_of_month && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfMonth', 'Days of Month')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_pattern.days_of_month.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yearly: Month and day */}
|
||||
{reviewingBlock.recurrence_type === 'YEARLY' && reviewingBlock.recurrence_pattern?.month && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.yearlyDate', 'Annual Date')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][reviewingBlock.recurrence_pattern.month - 1]} {reviewingBlock.recurrence_pattern.day}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence period (start/end) for recurring blocks */}
|
||||
{reviewingBlock.recurrence_type !== 'NONE' && (reviewingBlock.recurrence_start || reviewingBlock.recurrence_end) && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.effectivePeriod', 'Effective Period')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_start ? (
|
||||
new Date(reviewingBlock.recurrence_start + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : 'No start date'}
|
||||
{' - '}
|
||||
{reviewingBlock.recurrence_end ? (
|
||||
new Date(reviewingBlock.recurrence_end + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : 'Ongoing'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time range if not all-day */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.timeRange', 'Time')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.all_day !== false ? (
|
||||
t('timeBlocks.allDay', 'All Day')
|
||||
) : (
|
||||
<>
|
||||
{reviewingBlock.start_time?.slice(0, 5)} - {reviewingBlock.end_time?.slice(0, 5)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewingBlock.description && (
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 block mb-1">{t('timeBlocks.description', 'Description')}</span>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{reviewingBlock.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{reviewingBlock.created_by_name && (
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.requestedBy', 'Requested by')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.created_by_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<MessageSquare size={14} className="inline mr-1" />
|
||||
{t('timeBlocks.reviewNotes', 'Notes (optional)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={reviewNotes}
|
||||
onChange={(e) => setReviewNotes(e.target.value)}
|
||||
placeholder={t('timeBlocks.reviewNotesPlaceholder', 'Add a note for the requester...')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(reviewingBlock.id)}
|
||||
disabled={denyBlock.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{denyBlock.isPending ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<XCircle size={18} />
|
||||
)}
|
||||
{t('timeBlocks.deny', 'Deny')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(reviewingBlock.id)}
|
||||
disabled={approveBlock.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{approveBlock.isPending ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<CheckCircle size={18} />
|
||||
)}
|
||||
{t('timeBlocks.approve', 'Approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -599,6 +599,8 @@ export interface TimeBlock {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type ApprovalStatus = 'APPROVED' | 'PENDING' | 'DENIED';
|
||||
|
||||
export interface TimeBlockListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -619,6 +621,12 @@ export interface TimeBlockListItem {
|
||||
pattern_display?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
approval_status?: ApprovalStatus;
|
||||
reviewed_by?: number;
|
||||
reviewed_by_name?: string;
|
||||
reviewed_at?: string;
|
||||
review_notes?: string;
|
||||
created_by_name?: string;
|
||||
}
|
||||
|
||||
export interface BlockedDate {
|
||||
@@ -648,6 +656,7 @@ export interface TimeBlockConflictCheck {
|
||||
export interface MyBlocksResponse {
|
||||
business_blocks: TimeBlockListItem[];
|
||||
my_blocks: TimeBlockListItem[];
|
||||
resource_id: string;
|
||||
resource_name: string;
|
||||
resource_id: string | null;
|
||||
resource_name: string | null;
|
||||
can_self_approve: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user