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

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