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,388 @@
/**
* Contract Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import {
ContractTemplate,
Contract,
ContractPublicView,
ContractScope,
ContractTemplateStatus,
} from '../types';
// --- Contract Templates ---
/**
* Hook to fetch all contract templates for current business
*/
export const useContractTemplates = (status?: ContractTemplateStatus) => {
return useQuery<ContractTemplate[]>({
queryKey: ['contract-templates', status],
queryFn: async () => {
const params = status ? { status } : {};
const { data } = await apiClient.get('/contracts/templates/', { params });
return data.map((t: any) => ({
id: String(t.id),
name: t.name,
description: t.description || '',
content: t.content,
scope: t.scope as ContractScope,
status: t.status as ContractTemplateStatus,
expires_after_days: t.expires_after_days,
version: t.version,
version_notes: t.version_notes || '',
services: t.services || [],
created_by: t.created_by ? String(t.created_by) : null,
created_by_name: t.created_by_name || null,
created_at: t.created_at,
updated_at: t.updated_at,
}));
},
retry: false,
});
};
/**
* Hook to get a single contract template
*/
export const useContractTemplate = (id: string) => {
return useQuery<ContractTemplate>({
queryKey: ['contract-templates', id],
queryFn: async () => {
const { data } = await apiClient.get(`/contracts/templates/${id}/`);
return {
id: String(data.id),
name: data.name,
description: data.description || '',
content: data.content,
scope: data.scope as ContractScope,
status: data.status as ContractTemplateStatus,
expires_after_days: data.expires_after_days,
version: data.version,
version_notes: data.version_notes || '',
services: data.services || [],
created_by: data.created_by ? String(data.created_by) : null,
created_by_name: data.created_by_name || null,
created_at: data.created_at,
updated_at: data.updated_at,
};
},
enabled: !!id,
retry: false,
});
};
interface ContractTemplateInput {
name: string;
description?: string;
content: string;
scope: ContractScope;
status?: ContractTemplateStatus;
expires_after_days?: number | null;
version_notes?: string;
services?: string[];
}
/**
* Hook to create a contract template
*/
export const useCreateContractTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (templateData: ContractTemplateInput) => {
const { data } = await apiClient.post('/contracts/templates/', templateData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
},
});
};
/**
* Hook to update a contract template
*/
export const useUpdateContractTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
updates,
}: {
id: string;
updates: Partial<ContractTemplateInput>;
}) => {
const { data } = await apiClient.patch(`/contracts/templates/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
},
});
};
/**
* Hook to delete a contract template
*/
export const useDeleteContractTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/contracts/templates/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
},
});
};
/**
* Hook to duplicate a contract template
*/
export const useDuplicateContractTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/contracts/templates/${id}/duplicate/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
},
});
};
/**
* Hook to preview a contract template
*/
export const usePreviewContractTemplate = () => {
return useMutation({
mutationFn: async ({
id,
context,
}: {
id: string;
context?: Record<string, any>;
}) => {
const { data } = await apiClient.post(
`/contracts/templates/${id}/preview/`,
context || {}
);
return data;
},
});
};
// --- Contracts ---
/**
* Hook to fetch all contracts for current business
*/
export const useContracts = (filters?: {
status?: string;
customer?: string;
appointment?: string;
}) => {
return useQuery<Contract[]>({
queryKey: ['contracts', filters],
queryFn: async () => {
const { data } = await apiClient.get('/contracts/', {
params: filters,
});
return data.map((c: any) => ({
id: String(c.id),
template: String(c.template),
template_name: c.template_name,
template_version: c.template_version,
scope: c.scope as ContractScope,
status: c.status,
content: c.content,
customer: c.customer ? String(c.customer) : undefined,
customer_name: c.customer_name || undefined,
customer_email: c.customer_email || undefined,
appointment: c.appointment ? String(c.appointment) : undefined,
appointment_service_name: c.appointment_service_name || undefined,
appointment_start_time: c.appointment_start_time || undefined,
service: c.service ? String(c.service) : undefined,
service_name: c.service_name || undefined,
sent_at: c.sent_at,
signed_at: c.signed_at,
expires_at: c.expires_at,
voided_at: c.voided_at,
voided_reason: c.voided_reason,
public_token: c.public_token,
created_at: c.created_at,
updated_at: c.updated_at,
}));
},
retry: false,
});
};
/**
* Hook to get a single contract
*/
export const useContract = (id: string) => {
return useQuery<Contract>({
queryKey: ['contracts', id],
queryFn: async () => {
const { data } = await apiClient.get(`/contracts/${id}/`);
return {
id: String(data.id),
template: String(data.template),
template_name: data.template_name,
template_version: data.template_version,
scope: data.scope as ContractScope,
status: data.status,
content: data.content,
customer: data.customer ? String(data.customer) : undefined,
customer_name: data.customer_name || undefined,
customer_email: data.customer_email || undefined,
appointment: data.appointment ? String(data.appointment) : undefined,
appointment_service_name: data.appointment_service_name || undefined,
appointment_start_time: data.appointment_start_time || undefined,
service: data.service ? String(data.service) : undefined,
service_name: data.service_name || undefined,
sent_at: data.sent_at,
signed_at: data.signed_at,
expires_at: data.expires_at,
voided_at: data.voided_at,
voided_reason: data.voided_reason,
public_token: data.public_token,
created_at: data.created_at,
updated_at: data.updated_at,
};
},
enabled: !!id,
retry: false,
});
};
interface ContractInput {
template: string;
customer?: string;
appointment?: string;
service?: string;
}
/**
* Hook to create a contract
*/
export const useCreateContract = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (contractData: ContractInput) => {
const { data } = await apiClient.post('/contracts/', contractData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
};
/**
* Hook to send a contract to customer
*/
export const useSendContract = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/contracts/${id}/send/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
};
/**
* Hook to void a contract
*/
export const useVoidContract = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, reason }: { id: string; reason: string }) => {
const { data } = await apiClient.post(`/contracts/${id}/void/`, { reason });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
};
/**
* Hook to resend a contract
*/
export const useResendContract = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/contracts/${id}/resend/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
};
// --- Public Contract Access (no auth required) ---
/**
* Hook to fetch public contract view by token (no auth required)
*/
export const usePublicContract = (token: string) => {
return useQuery<ContractPublicView>({
queryKey: ['public-contracts', token],
queryFn: async () => {
// Use a plain axios instance without auth
const { data } = await apiClient.get(`/contracts/public/${token}/`);
return data;
},
enabled: !!token,
retry: false,
});
};
/**
* Hook to sign a contract (no auth required)
*/
export const useSignContract = () => {
return useMutation({
mutationFn: async ({
token,
signature_data,
signer_name,
signer_email,
}: {
token: string;
signature_data: string;
signer_name: string;
signer_email: string;
}) => {
const { data } = await apiClient.post(`/contracts/public/${token}/sign/`, {
signature_data,
signer_name,
signer_email,
});
return data;
},
});
};

View 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;
},
});
};