- Add export_legal endpoint for signed contracts that generates a ZIP with: - Signed contract PDF - Audit certificate PDF with signature details and hash verification - Machine-readable signature_record.json - Integrity verification report - README documentation - Add audit certificate template with: - Contract and signature information - Consent records with exact legal text - Document integrity verification (SHA-256 hash comparison) - ESIGN Act and UETA compliance statement - Update ContractSigning page for ESIGN/UETA compliance: - Consent checkbox text now matches backend-stored legal text - Added proper legal notice with ESIGN Act references - Add signed_at field to ContractListSerializer - Add view/print buttons for signed contracts in Contracts page - Allow viewing signed contracts via public signing URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
425 lines
11 KiB
TypeScript
425 lines
11 KiB
TypeScript
/**
|
|
* 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_html || c.content, // Backend returns content_html
|
|
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.signing_token || c.public_token, // Backend returns signing_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.signing_token || data.public_token, // Backend returns signing_token
|
|
created_at: data.created_at,
|
|
updated_at: data.updated_at,
|
|
};
|
|
},
|
|
enabled: !!id,
|
|
retry: false,
|
|
});
|
|
};
|
|
|
|
interface ContractInput {
|
|
template: string;
|
|
customer_id?: string;
|
|
event_id?: string;
|
|
send_email?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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 the public signing endpoint
|
|
const { data } = await apiClient.get(`/contracts/sign/${token}/`);
|
|
return data;
|
|
},
|
|
enabled: !!token,
|
|
retry: false,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to sign a contract (no auth required)
|
|
*/
|
|
export const useSignContract = () => {
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
token,
|
|
signer_name,
|
|
consent_checkbox_checked,
|
|
electronic_consent_given,
|
|
}: {
|
|
token: string;
|
|
signer_name: string;
|
|
consent_checkbox_checked: boolean;
|
|
electronic_consent_given: boolean;
|
|
}) => {
|
|
const { data } = await apiClient.post(`/contracts/sign/${token}/`, {
|
|
signer_name,
|
|
consent_checkbox_checked,
|
|
electronic_consent_given,
|
|
});
|
|
return data;
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to export a legal compliance package for a signed contract
|
|
*/
|
|
export const useExportLegalPackage = () => {
|
|
return useMutation({
|
|
mutationFn: async (id: string) => {
|
|
const response = await apiClient.get(`/contracts/${id}/export_legal/`, {
|
|
responseType: 'blob',
|
|
});
|
|
|
|
// Create download link
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
|
|
// Extract filename from Content-Disposition header if available
|
|
const contentDisposition = response.headers['content-disposition'];
|
|
let filename = `legal_export_${id}.zip`;
|
|
if (contentDisposition) {
|
|
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
|
|
if (filenameMatch && filenameMatch[1]) {
|
|
filename = filenameMatch[1];
|
|
}
|
|
}
|
|
|
|
link.setAttribute('download', filename);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
return { success: true };
|
|
},
|
|
});
|
|
};
|