- Create dateUtils.ts with helpers for UTC conversion and timezone display - Add TimezoneSerializerMixin to include business_timezone in API responses - Update GeneralSettings timezone dropdown with IANA identifiers - Apply timezone mixin to Event, TimeBlock, and field mobile serializers - Document timezone architecture in CLAUDE.md All times stored in UTC, converted for display based on business timezone. If business_timezone is null, uses user's local timezone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
|
* 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';
|
|
import { formatLocalDate } from '../../utils/dateUtils';
|
|
|
|
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 = formatLocalDate(day);
|
|
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;
|