- 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>
881 lines
41 KiB
TypeScript
881 lines
41 KiB
TypeScript
/**
|
|
* 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,
|
|
usePendingReviews,
|
|
useApproveTimeBlock,
|
|
useDenyTimeBlock,
|
|
} 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,
|
|
HourglassIcon,
|
|
CheckCircle,
|
|
XCircle,
|
|
MessageSquare,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} 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);
|
|
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 {
|
|
data: businessBlocks = [],
|
|
isLoading: businessLoading,
|
|
} = useTimeBlocks({ level: 'business' });
|
|
|
|
const {
|
|
data: resourceBlocks = [],
|
|
isLoading: resourceLoading,
|
|
} = useTimeBlocks({ level: 'resource' });
|
|
|
|
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;
|
|
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);
|
|
}
|
|
};
|
|
|
|
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
|
|
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>
|
|
|
|
{/* 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">
|
|
<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>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
};
|
|
|
|
export default TimeBlocks;
|