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:
poduck
2025-12-07 17:49:37 -05:00
parent 01020861c7
commit 410b46a896
27 changed files with 3192 additions and 1237 deletions

View File

@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
// Import pages
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const 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

View File

@@ -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[];
}

View File

@@ -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',

View File

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

View File

@@ -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
*/

View File

@@ -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",

View File

@@ -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 ? (

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

View File

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

View File

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