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

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