feat(time-blocks): Add comprehensive time blocking system with contracts

- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-04 17:19:12 -05:00
parent cf91bae24f
commit 8d0cc1e90a
63 changed files with 11863 additions and 61 deletions

View File

@@ -0,0 +1,555 @@
/**
* Time Blocks Management Page
*
* Allows business owners/managers to manage time blocks for business closures,
* holidays, and resource-specific unavailability.
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
TimeBlockListItem,
BlockType,
RecurrenceType,
} from '../types';
import {
useTimeBlocks,
useCreateTimeBlock,
useUpdateTimeBlock,
useDeleteTimeBlock,
useToggleTimeBlock,
useHolidays,
} from '../hooks/useTimeBlocks';
import { useResources } from '../hooks/useResources';
import Portal from '../components/Portal';
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
import {
Building2,
User,
Plus,
Pencil,
Trash2,
AlertTriangle,
Clock,
Calendar,
CalendarDays,
Ban,
AlertCircle,
Power,
PowerOff,
} from 'lucide-react';
type TimeBlockTab = 'business' | 'resource' | 'calendar';
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
NONE: 'One-time',
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
YEARLY: 'Yearly',
HOLIDAY: 'Holiday',
};
const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
HARD: 'Hard Block',
SOFT: 'Soft Block',
};
const TimeBlocks: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TimeBlockTab>('business');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Fetch data (include inactive blocks so users can re-enable them)
const {
data: businessBlocks = [],
isLoading: businessLoading,
} = useTimeBlocks({ level: 'business' });
const {
data: resourceBlocks = [],
isLoading: resourceLoading,
} = useTimeBlocks({ level: 'resource' });
const { data: holidays = [] } = useHolidays('US');
const { data: resources = [] } = useResources();
// Mutations
const createBlock = useCreateTimeBlock();
const updateBlock = useUpdateTimeBlock();
const deleteBlock = useDeleteTimeBlock();
const toggleBlock = useToggleTimeBlock();
// Current blocks based on tab
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
const isLoading = activeTab === 'business' ? businessLoading : resourceLoading;
// Group resource blocks by resource
const resourceBlocksGrouped = useMemo(() => {
const groups: Record<string, TimeBlockListItem[]> = {};
resourceBlocks.forEach((block) => {
const resourceId = block.resource || 'unassigned';
if (!groups[resourceId]) groups[resourceId] = [];
groups[resourceId].push(block);
});
return groups;
}, [resourceBlocks]);
// Modal handlers
const openCreateModal = () => {
setEditingBlock(null);
setIsModalOpen(true);
};
const openEditModal = (block: TimeBlockListItem) => {
setEditingBlock(block);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingBlock(null);
};
const handleDelete = async (id: string) => {
try {
await deleteBlock.mutateAsync(id);
setDeleteConfirmId(null);
} catch (error) {
console.error('Failed to delete time block:', error);
}
};
const handleToggle = async (id: string) => {
try {
await toggleBlock.mutateAsync(id);
} catch (error) {
console.error('Failed to toggle time block:', error);
}
};
// Render block type badge
const renderBlockTypeBadge = (type: BlockType) => (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
type === 'HARD'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}
>
{type === 'HARD' ? <Ban size={12} className="mr-1" /> : <AlertCircle size={12} className="mr-1" />}
{BLOCK_TYPE_LABELS[type]}
</span>
);
// Render recurrence type badge
const renderRecurrenceBadge = (type: RecurrenceType) => (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{type === 'HOLIDAY' ? (
<CalendarDays size={12} className="mr-1" />
) : type === 'NONE' ? (
<Clock size={12} className="mr-1" />
) : (
<Calendar size={12} className="mr-1" />
)}
{RECURRENCE_TYPE_LABELS[type]}
</span>
);
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('timeBlocks.title', 'Time Blocks')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('timeBlocks.subtitle', 'Manage business closures, holidays, and resource 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"
>
<Plus size={18} />
{t('timeBlocks.addBlock', 'Add Block')}
</button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-1" aria-label="Time block tabs">
<button
onClick={() => setActiveTab('business')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'business'
? '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'
}`}
>
<Building2 size={18} />
{t('timeBlocks.businessTab', 'Business Blocks')}
{businessBlocks.length > 0 && (
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
{businessBlocks.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('resource')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'resource'
? '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'
}`}
>
<User size={18} />
{t('timeBlocks.resourceTab', 'Resource Blocks')}
{resourceBlocks.length > 0 && (
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
{resourceBlocks.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('timeBlocks.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-4">
{activeTab === 'business' && (
<>
{/* Business Blocks Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-300">
<Building2 size={16} className="inline mr-2" />
{t('timeBlocks.businessInfo', 'Business blocks apply to all resources. Use these for holidays, company closures, and business-wide events.')}
</p>
</div>
{/* Business Blocks List */}
{businessBlocks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<CalendarDays 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('timeBlocks.noBusinessBlocks', 'No Business Blocks')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('timeBlocks.noBusinessBlocksDesc', 'Add holidays and business closures to prevent bookings during those times.')}
</p>
<button
onClick={() => openCreateModal()}
className="btn-primary inline-flex items-center gap-2"
>
<Plus size={18} />
{t('timeBlocks.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('timeBlocks.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('timeBlocks.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('timeBlocks.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('timeBlocks.actionsCol', 'Actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{businessBlocks.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 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>
)}
</>
)}
{activeTab === 'resource' && (
<>
{/* Resource Blocks Info */}
<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">
<User size={16} className="inline mr-2" />
{t('timeBlocks.resourceInfo', 'Resource blocks apply to specific staff or equipment. Use these for vacations, maintenance, or personal time.')}
</p>
</div>
{/* Resource Blocks List */}
{resourceBlocks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<User 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('timeBlocks.noResourceBlocks', 'No Resource Blocks')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('timeBlocks.noResourceBlocksDesc', 'Add time blocks for specific resources to manage their availability.')}
</p>
<button
onClick={() => openCreateModal()}
className="btn-primary inline-flex items-center gap-2"
>
<Plus size={18} />
{t('timeBlocks.addFirstBlock', 'Add First Block')}
</button>
</div>
) : (
<div className="space-y-4">
{resources.map((resource) => {
const blocks = resourceBlocksGrouped[resource.id] || [];
if (blocks.length === 0) return null;
return (
<div
key={resource.id}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
{resource.name}
</h3>
<button
onClick={() => openCreateModal()}
className="text-sm text-brand-600 hover:text-brand-700 flex items-center gap-1"
>
<Plus size={14} />
Add Block
</button>
</div>
<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">
{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 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>
)}
</>
)}
{activeTab === 'calendar' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<YearlyBlockCalendar
onBlockClick={(blockId) => {
// Find the block and open edit modal
const block = [...businessBlocks, ...resourceBlocks].find(b => b.id === blockId);
if (block) {
openEditModal(block);
}
}}
/>
</div>
)}
</div>
)}
{/* Create/Edit Modal */}
<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={resources}
isResourceLevel={activeTab === 'resource'}
/>
{/* Delete Confirmation Modal */}
{deleteConfirmId && (
<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-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('timeBlocks.deleteConfirmTitle', 'Delete Time Block?')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('timeBlocks.deleteConfirmDesc', 'This action cannot be undone.')}
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<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="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 ? (
<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.deleting', 'Deleting...')}
</span>
) : (
t('common.delete', 'Delete')
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default TimeBlocks;