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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user