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:
@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_self_approve_time_off',
|
||||
labelKey: 'staff.canSelfApproveTimeOff',
|
||||
labelDefault: 'Can self-approve time off',
|
||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
|
||||
@@ -155,6 +155,10 @@ interface TimeBlockCreatorModalProps {
|
||||
holidays: Holiday[];
|
||||
resources: Resource[];
|
||||
isResourceLevel?: boolean;
|
||||
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
|
||||
staffMode?: boolean;
|
||||
/** Pre-selected resource ID for staff mode */
|
||||
staffResourceId?: string | number | null;
|
||||
}
|
||||
|
||||
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
||||
@@ -168,6 +172,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
holidays,
|
||||
resources,
|
||||
isResourceLevel: initialIsResourceLevel = false,
|
||||
staffMode = false,
|
||||
staffResourceId = null,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
||||
@@ -177,7 +183,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
// Form state
|
||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||
const [description, setDescription] = useState(editingBlock?.description || '');
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
|
||||
// In staff mode, default to SOFT blocks (time-off requests)
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
|
||||
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
|
||||
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
||||
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
||||
@@ -270,7 +277,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setAllDay(true);
|
||||
setStartTime('09:00');
|
||||
setEndTime('17:00');
|
||||
setResourceId(null);
|
||||
// In staff mode, pre-select the staff's resource
|
||||
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
|
||||
setSelectedDates([]);
|
||||
setDaysOfWeek([]);
|
||||
setDaysOfMonth([]);
|
||||
@@ -279,10 +287,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setHolidayCodes([]);
|
||||
setRecurrenceStart('');
|
||||
setRecurrenceEnd('');
|
||||
setIsResourceLevel(initialIsResourceLevel);
|
||||
// In staff mode, always resource-level
|
||||
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel]);
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||
|
||||
// Apply preset configuration
|
||||
const applyPreset = (presetId: string) => {
|
||||
@@ -293,7 +302,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setTitle(preset.config.title);
|
||||
setRecurrenceType(preset.config.recurrence_type);
|
||||
setAllDay(preset.config.all_day);
|
||||
setBlockType(preset.config.block_type);
|
||||
// In staff mode, always use SOFT blocks regardless of preset
|
||||
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
|
||||
|
||||
if (preset.config.start_time) setStartTime(preset.config.start_time);
|
||||
if (preset.config.end_time) setEndTime(preset.config.end_time);
|
||||
@@ -367,12 +377,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In staff mode, always use the staff's resource ID
|
||||
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||
|
||||
const baseData: any = {
|
||||
description: description || undefined,
|
||||
block_type: blockType,
|
||||
recurrence_type: recurrenceType,
|
||||
all_day: allDay,
|
||||
resource: isResourceLevel ? resourceId : null,
|
||||
resource: isResourceLevel ? effectiveResourceId : null,
|
||||
};
|
||||
|
||||
if (!allDay) {
|
||||
@@ -425,7 +438,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
return true;
|
||||
case 'details':
|
||||
if (!title.trim()) return false;
|
||||
if (isResourceLevel && !resourceId) return false;
|
||||
// In staff mode, resource is auto-selected; otherwise check if selected
|
||||
if (isResourceLevel && !staffMode && !resourceId) return false;
|
||||
return true;
|
||||
case 'schedule':
|
||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||
@@ -556,63 +570,65 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
{/* Step 2: Details */}
|
||||
{step === 'details' && (
|
||||
<div className="space-y-6">
|
||||
{/* Block Level Selector */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
!isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
{/* Block Level Selector - Hidden in staff mode */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
!isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Business-wide
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects all resources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Business-wide
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects all resources
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResourceLevel(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Resource
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects one resource
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResourceLevel(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isResourceLevel
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Resource
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects one resource
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
@@ -642,8 +658,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource (if resource-level) */}
|
||||
{isResourceLevel && (
|
||||
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||
{isResourceLevel && !staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
@@ -661,52 +677,54 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block Type */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('HARD')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'HARD'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('HARD')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'HARD'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completely prevents bookings. Cannot be overridden.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('SOFT')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'SOFT'
|
||||
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completely prevents bookings. Cannot be overridden.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('SOFT')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
blockType === 'SOFT'
|
||||
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows a warning but allows bookings with override.
|
||||
</p>
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows a warning but allows bookings with override.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle & Time */}
|
||||
<div>
|
||||
@@ -1188,11 +1206,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{isResourceLevel && resourceId && (
|
||||
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||
<div className="flex justify-between py-2">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
{resources.find(r => r.id === resourceId)?.name || resourceId}
|
||||
{resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user