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:
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Time Block Management Hooks
|
||||
*
|
||||
* Provides hooks for managing time blocks and holidays.
|
||||
* Time blocks allow businesses to block off time for closures, holidays,
|
||||
* resource unavailability, and recurring patterns.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
TimeBlock,
|
||||
TimeBlockListItem,
|
||||
BlockedDate,
|
||||
Holiday,
|
||||
TimeBlockConflictCheck,
|
||||
MyBlocksResponse,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
} from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
// =============================================================================
|
||||
|
||||
export interface TimeBlockFilters {
|
||||
level?: 'business' | 'resource';
|
||||
resource_id?: string;
|
||||
block_type?: BlockType;
|
||||
recurrence_type?: RecurrenceType;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface BlockedDatesParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
resource_id?: string;
|
||||
include_business?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimeBlockData {
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CheckConflictsData {
|
||||
recurrence_type: RecurrenceType;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
resource_id?: string | null;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Time Block Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch time blocks with optional filters
|
||||
*/
|
||||
export const useTimeBlocks = (filters?: TimeBlockFilters) => {
|
||||
return useQuery<TimeBlockListItem[]>({
|
||||
queryKey: ['time-blocks', filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.level) params.append('level', filters.level);
|
||||
if (filters?.resource_id) params.append('resource_id', filters.resource_id);
|
||||
if (filters?.block_type) params.append('block_type', filters.block_type);
|
||||
if (filters?.recurrence_type) params.append('recurrence_type', filters.recurrence_type);
|
||||
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/?${params}`);
|
||||
return data.map((block: any) => ({
|
||||
...block,
|
||||
id: String(block.id),
|
||||
resource: block.resource ? String(block.resource) : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single time block
|
||||
*/
|
||||
export const useTimeBlock = (id: string) => {
|
||||
return useQuery<TimeBlock>({
|
||||
queryKey: ['time-blocks', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/time-blocks/${id}/`);
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id),
|
||||
resource: data.resource ? String(data.resource) : null,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get blocked dates for calendar visualization
|
||||
*/
|
||||
export const useBlockedDates = (params: BlockedDatesParams) => {
|
||||
return useQuery<BlockedDate[]>({
|
||||
queryKey: ['blocked-dates', params],
|
||||
queryFn: async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
});
|
||||
if (params.resource_id) queryParams.append('resource_id', params.resource_id);
|
||||
if (params.include_business !== undefined) {
|
||||
queryParams.append('include_business', String(params.include_business));
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/blocked_dates/?${queryParams}`);
|
||||
return data.blocked_dates.map((block: any) => ({
|
||||
...block,
|
||||
resource_id: block.resource_id ? String(block.resource_id) : null,
|
||||
time_block_id: String(block.time_block_id),
|
||||
}));
|
||||
},
|
||||
enabled: !!params.start_date && !!params.end_date,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get time blocks for the current staff member
|
||||
*/
|
||||
export const useMyBlocks = () => {
|
||||
return useQuery<MyBlocksResponse>({
|
||||
queryKey: ['my-blocks'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/time-blocks/my_blocks/');
|
||||
return {
|
||||
business_blocks: data.business_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
my_blocks: data.my_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
resource_id: String(data.resource_id),
|
||||
resource_name: data.resource_name,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a time block
|
||||
*/
|
||||
export const useCreateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (blockData: CreateTimeBlockData) => {
|
||||
const payload = {
|
||||
...blockData,
|
||||
resource: blockData.resource ? parseInt(blockData.resource) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/', payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a time block
|
||||
*/
|
||||
export const useUpdateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<CreateTimeBlockData> }) => {
|
||||
const payload: any = { ...updates };
|
||||
if (updates.resource !== undefined) {
|
||||
payload.resource = updates.resource ? parseInt(updates.resource) : null;
|
||||
}
|
||||
const { data } = await apiClient.patch(`/time-blocks/${id}/`, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a time block
|
||||
*/
|
||||
export const useDeleteTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/time-blocks/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle a time block's active status
|
||||
*/
|
||||
export const useToggleTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/toggle/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for conflicts before creating a time block
|
||||
*/
|
||||
export const useCheckConflicts = () => {
|
||||
return useMutation<TimeBlockConflictCheck, Error, CheckConflictsData>({
|
||||
mutationFn: async (checkData) => {
|
||||
const payload = {
|
||||
...checkData,
|
||||
resource_id: checkData.resource_id ? parseInt(checkData.resource_id) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/check_conflicts/', payload);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Holiday Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch holidays
|
||||
*/
|
||||
export const useHolidays = (country?: string) => {
|
||||
return useQuery<Holiday[]>({
|
||||
queryKey: ['holidays', country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single holiday by code
|
||||
*/
|
||||
export const useHoliday = (code: string) => {
|
||||
return useQuery<Holiday>({
|
||||
queryKey: ['holidays', code],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/holidays/${code}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!code,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get holiday dates for a specific year
|
||||
*/
|
||||
export const useHolidayDates = (year?: number, country?: string) => {
|
||||
return useQuery<{ year: number; holidays: { code: string; name: string; date: string }[] }>({
|
||||
queryKey: ['holiday-dates', year, country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.append('year', String(year));
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/dates/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user