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:
@@ -20,9 +20,13 @@ const routeToHelpPath: Record<string, string> = {
|
||||
'/services': '/help/services',
|
||||
'/resources': '/help/resources',
|
||||
'/staff': '/help/staff',
|
||||
'/time-blocks': '/help/time-blocks',
|
||||
'/my-availability': '/help/time-blocks',
|
||||
'/messages': '/help/messages',
|
||||
'/tickets': '/help/ticketing',
|
||||
'/payments': '/help/payments',
|
||||
'/contracts': '/help/contracts',
|
||||
'/contracts/templates': '/help/contracts',
|
||||
'/plugins': '/help/plugins',
|
||||
'/plugins/marketplace': '/help/plugins',
|
||||
'/plugins/my-plugins': '/help/plugins',
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
HelpCircle,
|
||||
Clock,
|
||||
Plug,
|
||||
FileSignature,
|
||||
CalendarOff,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
@@ -121,6 +123,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
/>
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
<SidebarItem
|
||||
to="/my-availability"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.myAvailability', 'My Availability')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Manage Section - Staff+ */}
|
||||
@@ -145,12 +155,26 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal file
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* TimeBlockCalendarOverlay - Renders time block overlays on the scheduler calendar
|
||||
*
|
||||
* Shows blocked time periods with visual styling:
|
||||
* - Hard blocks: Red with diagonal stripes, 50% opacity
|
||||
* - Soft blocks: Yellow with dotted border, 30% opacity
|
||||
* - Business blocks: Full-width across the lane
|
||||
* - Resource blocks: Only on matching resource lane
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { BlockedDate, BlockType } from '../../types';
|
||||
|
||||
interface TimeBlockCalendarOverlayProps {
|
||||
blockedDates: BlockedDate[];
|
||||
resourceId: string;
|
||||
viewDate: Date;
|
||||
zoomLevel: number;
|
||||
pixelsPerMinute: number;
|
||||
startHour: number;
|
||||
dayWidth: number;
|
||||
laneHeight: number;
|
||||
days: Date[];
|
||||
onDayClick?: (day: Date) => void;
|
||||
}
|
||||
|
||||
interface TimeBlockTooltipProps {
|
||||
block: BlockedDate;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
const TimeBlockTooltip: React.FC<TimeBlockTooltipProps> = ({ block, position }) => {
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[100] px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg max-w-xs pointer-events-none"
|
||||
style={{
|
||||
left: position.x + 10,
|
||||
top: position.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{block.title}</div>
|
||||
<div className="text-xs text-gray-300 mt-1">
|
||||
{block.block_type === 'HARD' ? 'Hard Block' : 'Soft Block'}
|
||||
{block.all_day ? ' (All Day)' : ` (${block.start_time} - ${block.end_time})`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
blockedDates,
|
||||
resourceId,
|
||||
viewDate,
|
||||
zoomLevel,
|
||||
pixelsPerMinute,
|
||||
startHour,
|
||||
dayWidth,
|
||||
laneHeight,
|
||||
days,
|
||||
onDayClick,
|
||||
}) => {
|
||||
const [hoveredBlock, setHoveredBlock] = useState<{ block: BlockedDate; position: { x: number; y: number } } | null>(null);
|
||||
|
||||
// Filter blocks for this resource (includes business-level blocks where resource_id is null)
|
||||
const relevantBlocks = useMemo(() => {
|
||||
return blockedDates.filter(
|
||||
(block) => block.resource_id === null || block.resource_id === resourceId
|
||||
);
|
||||
}, [blockedDates, resourceId]);
|
||||
|
||||
// Calculate block positions for each day
|
||||
const blockOverlays = useMemo(() => {
|
||||
const overlays: Array<{
|
||||
block: BlockedDate;
|
||||
left: number;
|
||||
width: number;
|
||||
dayIndex: number;
|
||||
}> = [];
|
||||
|
||||
relevantBlocks.forEach((block) => {
|
||||
// Parse date string as local date, not UTC
|
||||
// "2025-12-06" should be Dec 6 in local timezone, not UTC
|
||||
const [year, month, dayNum] = block.date.split('-').map(Number);
|
||||
const blockDate = new Date(year, month - 1, dayNum);
|
||||
blockDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Find which day this block falls on
|
||||
days.forEach((day, dayIndex) => {
|
||||
const dayStart = new Date(day);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
if (blockDate.getTime() === dayStart.getTime()) {
|
||||
let left: number;
|
||||
let width: number;
|
||||
|
||||
if (block.all_day) {
|
||||
// Full day block
|
||||
left = dayIndex * dayWidth;
|
||||
width = dayWidth;
|
||||
} else if (block.start_time && block.end_time) {
|
||||
// Partial day block
|
||||
const [startHours, startMins] = block.start_time.split(':').map(Number);
|
||||
const [endHours, endMins] = block.end_time.split(':').map(Number);
|
||||
|
||||
const startMinutes = (startHours - startHour) * 60 + startMins;
|
||||
const endMinutes = (endHours - startHour) * 60 + endMins;
|
||||
|
||||
left = dayIndex * dayWidth + startMinutes * pixelsPerMinute * zoomLevel;
|
||||
width = (endMinutes - startMinutes) * pixelsPerMinute * zoomLevel;
|
||||
} else {
|
||||
// Default to full day if no times specified
|
||||
left = dayIndex * dayWidth;
|
||||
width = dayWidth;
|
||||
}
|
||||
|
||||
overlays.push({
|
||||
block,
|
||||
left,
|
||||
width,
|
||||
dayIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return overlays;
|
||||
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
||||
|
||||
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
if (isBusinessLevel) {
|
||||
// Business blocks: Red (hard) / Amber (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(239, 68, 68, 0.3) 5px,
|
||||
rgba(239, 68, 68, 0.5) 5px,
|
||||
rgba(239, 68, 68, 0.5) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(251, 191, 36, 0.2)',
|
||||
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Resource blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => {
|
||||
setHoveredBlock({
|
||||
block,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (hoveredBlock) {
|
||||
setHoveredBlock({
|
||||
...hoveredBlock,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredBlock(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockOverlays.map((overlay, index) => {
|
||||
const isBusinessLevel = overlay.block.resource_id === null;
|
||||
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${overlay.block.time_block_id}-${overlay.dayIndex}-${index}`}
|
||||
style={{
|
||||
...style,
|
||||
left: overlay.left,
|
||||
width: overlay.width,
|
||||
cursor: onDayClick ? 'pointer' : 'default',
|
||||
}}
|
||||
onMouseEnter={(e) => handleMouseEnter(e, overlay.block)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
||||
>
|
||||
{/* Block level indicator */}
|
||||
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
|
||||
isBusinessLevel
|
||||
? 'bg-red-600'
|
||||
: 'bg-purple-600'
|
||||
}`}>
|
||||
{isBusinessLevel ? 'B' : 'R'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredBlock && (
|
||||
<TimeBlockTooltip block={hoveredBlock.block} position={hoveredBlock.position} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBlockCalendarOverlay;
|
||||
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal file
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* YearlyBlockCalendar - Shows 12-month calendar grid with blocked dates
|
||||
*
|
||||
* Displays:
|
||||
* - Red cells for hard blocks
|
||||
* - Yellow cells for soft blocks
|
||||
* - "B" badge for business-level blocks
|
||||
* - Click to view/edit block
|
||||
* - Year selector
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
|
||||
import { BlockedDate, TimeBlockListItem } from '../../types';
|
||||
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
|
||||
|
||||
interface YearlyBlockCalendarProps {
|
||||
resourceId?: string;
|
||||
onBlockClick?: (blockId: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
|
||||
resourceId,
|
||||
onBlockClick,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [selectedBlock, setSelectedBlock] = useState<BlockedDate | null>(null);
|
||||
|
||||
// Fetch blocked dates for the entire year
|
||||
const blockedDatesParams = useMemo(() => ({
|
||||
start_date: `${year}-01-01`,
|
||||
end_date: `${year + 1}-01-01`,
|
||||
resource_id: resourceId,
|
||||
include_business: true,
|
||||
}), [year, resourceId]);
|
||||
|
||||
const { data: blockedDates = [], isLoading } = useBlockedDates(blockedDatesParams);
|
||||
|
||||
// Build a map of date -> blocked dates for quick lookup
|
||||
const blockedDateMap = useMemo(() => {
|
||||
const map = new Map<string, BlockedDate[]>();
|
||||
blockedDates.forEach(block => {
|
||||
const dateKey = block.date;
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, []);
|
||||
}
|
||||
map.get(dateKey)!.push(block);
|
||||
});
|
||||
return map;
|
||||
}, [blockedDates]);
|
||||
|
||||
const getDaysInMonth = (month: number): Date[] => {
|
||||
const days: Date[] = [];
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Add empty cells for days before the first of the month
|
||||
const startPadding = firstDay.getDay();
|
||||
for (let i = 0; i < startPadding; i++) {
|
||||
days.push(null as any);
|
||||
}
|
||||
|
||||
// Add each day of the month
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
days.push(new Date(year, month, day));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const getBlockStyle = (blocks: BlockedDate[]): string => {
|
||||
// Check if any block is a hard block
|
||||
const hasHardBlock = blocks.some(b => b.block_type === 'HARD');
|
||||
const hasBusinessBlock = blocks.some(b => b.resource_id === null);
|
||||
|
||||
if (hasHardBlock) {
|
||||
return hasBusinessBlock
|
||||
? 'bg-red-500 text-white font-bold'
|
||||
: 'bg-red-400 text-white';
|
||||
}
|
||||
return hasBusinessBlock
|
||||
? 'bg-yellow-400 text-yellow-900 font-bold'
|
||||
: 'bg-yellow-300 text-yellow-900';
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date, blocks: BlockedDate[]) => {
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
if (blocks.length === 1 && onBlockClick) {
|
||||
onBlockClick(blocks[0].time_block_id);
|
||||
} else {
|
||||
// Show the first block in the popup, could be enhanced to show all
|
||||
setSelectedBlock(blocks[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMonth = (month: number) => {
|
||||
const days = getDaysInMonth(month);
|
||||
|
||||
return (
|
||||
<div key={month} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{MONTHS[month]}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1">
|
||||
{WEEKDAYS.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-center text-[10px] font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Days grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{days.map((day, i) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${i}`} className="aspect-square" />;
|
||||
}
|
||||
|
||||
const dateKey = day.toISOString().split('T')[0];
|
||||
const blocks = blockedDateMap.get(dateKey) || [];
|
||||
const hasBlocks = blocks.length > 0;
|
||||
const isToday = new Date().toDateString() === day.toDateString();
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
onClick={() => handleDayClick(day, blocks)}
|
||||
disabled={!hasBlocks}
|
||||
className={`
|
||||
aspect-square flex items-center justify-center text-[11px] rounded
|
||||
${hasBlocks
|
||||
? `${getBlockStyle(blocks)} cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-400 dark:hover:ring-gray-500`
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
${isToday && !hasBlocks ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
||||
${!hasBlocks ? 'cursor-default' : ''}
|
||||
transition-all
|
||||
`}
|
||||
title={blocks.map(b => b.title).join(', ') || undefined}
|
||||
>
|
||||
{day.getDate()}
|
||||
{hasBlocks && blocks.some(b => b.resource_id === null) && (
|
||||
<span className="absolute text-[8px] font-bold top-0 right-0">B</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'p-4'}>
|
||||
{/* Header with year navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.yearlyCalendar', 'Yearly Calendar')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setYear(y => y - 1)}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white min-w-[60px] text-center">
|
||||
{year}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setYear(y => y + 1)}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setYear(new Date().getFullYear())}
|
||||
className="ml-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.today', 'Today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.hardBlock', 'Hard Block')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-yellow-400 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.softBlock', 'Soft Block')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center text-[8px] font-bold text-gray-600 dark:text-gray-300">
|
||||
B
|
||||
</div>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.businessLevel', 'Business Level')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar grid */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 12 }, (_, i) => renderMonth(i))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block detail popup */}
|
||||
{selectedBlock && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setSelectedBlock(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-4 max-w-sm w-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedBlock.title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedBlock(null)}
|
||||
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
<span className="font-medium">{t('timeBlocks.type', 'Type')}:</span>{' '}
|
||||
{selectedBlock.block_type === 'HARD' ? t('timeBlocks.hardBlock', 'Hard Block') : t('timeBlocks.softBlock', 'Soft Block')}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">{t('common.date', 'Date')}:</span>{' '}
|
||||
{new Date(selectedBlock.date).toLocaleDateString()}
|
||||
</p>
|
||||
{!selectedBlock.all_day && (
|
||||
<p>
|
||||
<span className="font-medium">{t('common.time', 'Time')}:</span>{' '}
|
||||
{selectedBlock.start_time} - {selectedBlock.end_time}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">{t('timeBlocks.level', 'Level')}:</span>{' '}
|
||||
{selectedBlock.resource_id === null ? t('timeBlocks.businessLevel', 'Business Level') : t('timeBlocks.resourceLevel', 'Resource Level')}
|
||||
</p>
|
||||
</div>
|
||||
{onBlockClick && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onBlockClick(selectedBlock.time_block_id);
|
||||
setSelectedBlock(null);
|
||||
}}
|
||||
className="mt-4 w-full px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('common.viewDetails', 'View Details')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YearlyBlockCalendar;
|
||||
Reference in New Issue
Block a user