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

@@ -41,6 +41,8 @@ const Payments = React.lazy(() => import('./pages/Payments'));
const Resources = React.lazy(() => import('./pages/Resources'));
const Services = React.lazy(() => import('./pages/Services'));
const Staff = React.lazy(() => import('./pages/Staff'));
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
@@ -78,8 +80,10 @@ const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
@@ -98,6 +102,9 @@ const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Pl
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -307,6 +314,7 @@ const AppContent: React.FC = () => {
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
@@ -340,6 +348,7 @@ const AppContent: React.FC = () => {
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
@@ -367,6 +376,7 @@ const AppContent: React.FC = () => {
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
@@ -671,8 +681,10 @@ const AppContent: React.FC = () => {
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/contracts" element={<HelpContracts />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
@@ -775,6 +787,46 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/time-blocks"
element={
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts"
element={
hasAccess(['owner', 'manager']) ? (
<Contracts />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts/templates"
element={
hasAccess(['owner', 'manager']) ? (
<ContractTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={

View File

@@ -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',

View File

@@ -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>
)}

View 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;

File diff suppressed because it is too large Load Diff

View 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;

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

View File

@@ -92,6 +92,7 @@
"platformSettings": "Platform Settings",
"tickets": "Tickets",
"help": "Help",
"contracts": "Contracts",
"platformGuide": "Platform Guide",
"ticketingHelp": "Ticketing System",
"apiDocs": "API Docs",
@@ -409,6 +410,106 @@
"sendReply": "Send Reply",
"ticketClosedNoReply": "This ticket is closed. If you need further assistance, please open a new support request."
},
"contracts": {
"title": "Contracts",
"description": "Manage contract templates and sent contracts",
"templates": "Templates",
"allContracts": "All Contracts",
"createTemplate": "Create Template",
"createContract": "Create Contract",
"editTemplate": "Edit Template",
"viewContract": "View Contract",
"noTemplates": "No contract templates yet",
"noContracts": "No contracts yet",
"templateName": "Template Name",
"templateDescription": "Description",
"content": "Content",
"scope": {
"label": "Scope",
"customer": "Customer Agreement",
"appointment": "Appointment Agreement"
},
"status": {
"label": "Status",
"draft": "Draft",
"active": "Active",
"archived": "Archived",
"pending": "Pending Signature",
"signed": "Signed",
"expired": "Expired",
"voided": "Voided"
},
"expiresAfterDays": "Expires After (Days)",
"expiresAfterDaysHint": "Leave empty for no expiration",
"versionNotes": "Version Notes",
"versionNotesPlaceholder": "What changed in this version?",
"services": "Applicable Services",
"servicesHint": "Leave empty to apply to all services",
"customer": "Customer",
"appointment": "Appointment",
"service": "Service",
"sentAt": "Sent At",
"signedAt": "Signed At",
"expiresAt": "Expires At",
"actions": {
"send": "Send Contract",
"resend": "Resend Contract",
"void": "Void Contract",
"duplicate": "Duplicate Template",
"preview": "Preview",
"delete": "Delete"
},
"sendContract": {
"title": "Send Contract",
"selectCustomer": "Select Customer",
"selectAppointment": "Select Appointment (Optional)",
"selectService": "Select Service (Optional)",
"send": "Send",
"success": "Contract sent successfully",
"error": "Failed to send contract"
},
"voidContract": {
"title": "Void Contract",
"reason": "Reason for voiding",
"reasonPlaceholder": "Why is this contract being voided?",
"confirm": "Void Contract",
"success": "Contract voided successfully",
"error": "Failed to void contract"
},
"preview": {
"title": "Preview Contract",
"sampleData": "Using sample data for preview"
},
"signing": {
"title": "Sign Contract",
"businessName": "{{businessName}}",
"contractFor": "Contract for {{customerName}}",
"pleaseReview": "Please review and sign this contract",
"signerName": "Your Full Name",
"signerEmail": "Your Email",
"signatureLabel": "Sign Below",
"signaturePlaceholder": "Draw your signature here",
"clearSignature": "Clear",
"agreeToTerms": "I agree to the terms and conditions",
"submitSignature": "Submit Signature",
"submitting": "Submitting...",
"success": "Contract signed successfully!",
"error": "Failed to sign contract",
"expired": "This contract has expired",
"alreadySigned": "This contract has already been signed",
"notFound": "Contract not found",
"signedBy": "Signed by {{name}} on {{date}}",
"thankYou": "Thank you for signing!"
},
"errors": {
"loadFailed": "Failed to load contracts",
"createFailed": "Failed to create contract",
"updateFailed": "Failed to update contract",
"deleteFailed": "Failed to delete contract",
"sendFailed": "Failed to send contract",
"voidFailed": "Failed to void contract"
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome, {{name}}!",

View File

@@ -0,0 +1,258 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { FileSignature, CheckCircle, XCircle, Loader } from 'lucide-react';
import { usePublicContract, useSignContract } from '../hooks/useContracts';
const ContractSigning: React.FC = () => {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const [signerName, setSignerName] = useState('');
const [signerEmail, setSignerEmail] = useState('');
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [signatureData, setSignatureData] = useState('');
const { data: contractData, isLoading, error } = usePublicContract(token || '');
const signMutation = useSignContract();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token || !signatureData || !agreedToTerms) return;
try {
await signMutation.mutateAsync({
token,
signature_data: signatureData,
signer_name: signerName,
signer_email: signerEmail,
});
} catch (error) {
console.error('Failed to sign contract:', error);
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !contractData) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{t('contracts.signing.notFound')}
</h2>
<p className="text-gray-600">
This contract link is invalid or has expired.
</p>
</div>
</div>
);
}
if (contractData.contract.status === 'SIGNED') {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{t('contracts.signing.alreadySigned')}
</h2>
{contractData.signature && (
<p className="text-gray-600">
{t('contracts.signing.signedBy', {
name: contractData.signature.signer_name,
date: new Date(contractData.signature.signed_at).toLocaleDateString(),
})}
</p>
)}
</div>
</div>
);
}
if (contractData.is_expired) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{t('contracts.signing.expired')}
</h2>
<p className="text-gray-600">
This contract has expired and can no longer be signed.
</p>
</div>
</div>
);
}
if (signMutation.isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{t('contracts.signing.success')}
</h2>
<p className="text-gray-600 mb-4">
{t('contracts.signing.thankYou')}
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
<div className="flex items-center gap-3 mb-4">
{contractData.business.logo_url ? (
<img
src={contractData.business.logo_url}
alt={contractData.business.name}
className="h-12 object-contain"
/>
) : (
<FileSignature className="w-12 h-12 text-blue-600" />
)}
<div>
<h1 className="text-2xl font-bold text-gray-900">
{contractData.business.name}
</h1>
<p className="text-gray-600">{contractData.template.name}</p>
</div>
</div>
{contractData.customer && (
<p className="text-gray-600">
{t('contracts.signing.contractFor', {
customerName: contractData.customer.name,
})}
</p>
)}
</div>
{/* Contract Content */}
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
/>
</div>
{/* Signature Form */}
{contractData.can_sign && (
<div className="bg-white rounded-lg shadow-lg p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{t('contracts.signing.title')}
</h2>
<p className="text-gray-600 mb-6">
{t('contracts.signing.pleaseReview')}
</p>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('contracts.signing.signerName')}
</label>
<input
type="text"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('contracts.signing.signerEmail')}
</label>
<input
type="email"
value={signerEmail}
onChange={(e) => setSignerEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('contracts.signing.signatureLabel')}
</label>
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
<textarea
placeholder={t('contracts.signing.signaturePlaceholder')}
value={signatureData}
onChange={(e) => setSignatureData(e.target.value)}
className="w-full h-32 p-2 border border-gray-200 rounded resize-none"
required
/>
<button
type="button"
onClick={() => setSignatureData('')}
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
>
{t('contracts.signing.clearSignature')}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="agree"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
required
/>
<label htmlFor="agree" className="text-sm text-gray-700">
{t('contracts.signing.agreeToTerms')}
</label>
</div>
<button
type="submit"
disabled={signMutation.isPending || !agreedToTerms}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{signMutation.isPending ? (
<>
<Loader className="w-5 h-5 animate-spin" />
{t('contracts.signing.submitting')}
</>
) : (
t('contracts.signing.submitSignature')
)}
</button>
{signMutation.isError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">
{t('contracts.signing.error')}
</p>
</div>
)}
</div>
</form>
</div>
)}
</div>
</div>
);
};
export default ContractSigning;

View File

@@ -0,0 +1,527 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
import {
FileSignature, Plus, Search, ArrowLeft, Pencil, Trash2, X, Loader2,
Eye, Copy, Check, AlertCircle,
} from 'lucide-react';
import {
useContractTemplates,
useCreateContractTemplate,
useUpdateContractTemplate,
useDeleteContractTemplate,
} from '../hooks/useContracts';
import apiClient from '../api/client';
import { ContractTemplate, ContractTemplateStatus, ContractScope } from '../types';
interface TemplateFormData {
name: string;
description: string;
content: string;
scope: ContractScope;
status: ContractTemplateStatus;
expires_after_days: number | null;
}
const ContractTemplates: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<ContractTemplateStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
// Auto-open create modal if ?create=true is in URL
useEffect(() => {
if (searchParams.get('create') === 'true') {
setIsModalOpen(true);
// Remove the param from URL to prevent re-opening on refresh
searchParams.delete('create');
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, setSearchParams]);
const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const { data: templates, isLoading, error } = useContractTemplates();
const createTemplate = useCreateContractTemplate();
const updateTemplate = useUpdateContractTemplate();
const deleteTemplate = useDeleteContractTemplate();
const [formData, setFormData] = useState<TemplateFormData>({
name: '',
description: '',
content: '',
scope: 'APPOINTMENT',
status: 'DRAFT',
expires_after_days: null,
});
// Filter templates by status and search term
const filteredTemplates = useMemo(() => {
if (!templates) return [];
return templates.filter((template) => {
const matchesTab = activeTab === 'all' || template.status === activeTab;
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
return matchesTab && matchesSearch;
});
}, [templates, activeTab, searchTerm]);
// Count templates by status
const statusCounts = useMemo(() => {
if (!templates) return { all: 0, ACTIVE: 0, DRAFT: 0, ARCHIVED: 0 };
return {
all: templates.length,
ACTIVE: templates.filter((t) => t.status === 'ACTIVE').length,
DRAFT: templates.filter((t) => t.status === 'DRAFT').length,
ARCHIVED: templates.filter((t) => t.status === 'ARCHIVED').length,
};
}, [templates]);
const resetForm = () => {
setFormData({
name: '',
description: '',
content: '',
scope: 'APPOINTMENT',
status: 'DRAFT',
expires_after_days: null,
});
setEditingTemplate(null);
};
const openCreateModal = () => {
resetForm();
setIsModalOpen(true);
};
const openEditModal = (template: ContractTemplate) => {
setEditingTemplate(template);
setFormData({
name: template.name,
description: template.description,
content: template.content,
scope: template.scope,
status: template.status,
expires_after_days: template.expires_after_days,
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTemplate) {
await updateTemplate.mutateAsync({
id: editingTemplate.id,
updates: formData,
});
} else {
await createTemplate.mutateAsync(formData);
}
setIsModalOpen(false);
resetForm();
} catch (error) {
console.error('Failed to save template:', error);
}
};
const handleDelete = async (id: string) => {
try {
await deleteTemplate.mutateAsync(id);
setDeleteConfirmId(null);
} catch (error) {
console.error('Failed to delete template:', error);
}
};
const getStatusBadge = (status: ContractTemplateStatus) => {
const styles = {
ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
DRAFT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
ARCHIVED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
{t(`contracts.status.${status.toLowerCase()}`)}
</span>
);
};
const getScopeBadge = (scope: ContractScope) => {
const styles = {
CUSTOMER: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
APPOINTMENT: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[scope]}`}>
{scope === 'CUSTOMER' ? 'Customer-Level' : 'Per Appointment'}
</span>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
return (
<div className="p-8">
<div className="mb-6">
<Link
to="/contracts"
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 mb-4"
>
<ArrowLeft size={20} />
{t('common.back')}
</Link>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<FileSignature className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold dark:text-white">{t('contracts.templates')}</h1>
</div>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Plus size={20} />
{t('contracts.createTemplate')}
</button>
</div>
<p className="text-gray-600 dark:text-gray-400">
Create and manage reusable contract templates with variable placeholders
</p>
</div>
{/* Search and Status Tabs */}
<div className="mb-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('common.search')}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-4">
{(['all', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
{tab === 'all' ? 'All' : t(`contracts.status.${tab.toLowerCase()}`)}
<span className="ml-2 px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
{statusCounts[tab]}
</span>
</button>
))}
</nav>
</div>
</div>
{/* Templates List */}
{filteredTemplates.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FileSignature className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-lg font-medium mb-2">
{searchTerm ? 'No templates found' : t('contracts.noTemplates')}
</p>
<p className="text-sm mb-4">
{searchTerm
? 'Try adjusting your search terms'
: 'Create a template to get started with digital contracts'}
</p>
{!searchTerm && (
<button
onClick={openCreateModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={20} />
{t('contracts.createTemplate')}
</button>
)}
</div>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Template
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Scope
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Version
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredTemplates.map((template) => (
<tr key={template.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4">
<div>
<div className="font-medium text-gray-900 dark:text-white">{template.name}</div>
{template.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
{template.description}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
{getScopeBadge(template.scope)}
</td>
<td className="px-6 py-4">
{getStatusBadge(template.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
v{template.version}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={async () => {
try {
// Fetch PDF with authentication
const response = await apiClient.get(
`/contracts/templates/${template.id}/preview_pdf/`,
{ responseType: 'blob' }
);
// Create blob URL and open in new tab
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
} catch (error) {
console.error('Failed to load PDF preview:', error);
alert('Failed to load PDF preview. PDF generation may not be available.');
}
}}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Preview PDF"
>
<Eye size={18} />
</button>
<button
onClick={() => openEditModal(template)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Edit"
>
<Pencil size={18} />
</button>
<button
onClick={() => setDeleteConfirmId(template.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Delete"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create/Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsModalOpen(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{editingTemplate ? 'Edit Template' : 'Create Template'}
</h2>
<button
onClick={() => setIsModalOpen(false)}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Template Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Scope *
</label>
<select
value={formData.scope}
onChange={(e) => setFormData({ ...formData, scope: e.target.value as ContractScope })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
>
<option value="APPOINTMENT">Per Appointment (e.g., liability waivers)</option>
<option value="CUSTOMER">Customer-Level (e.g., terms of service)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ContractTemplateStatus })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
>
<option value="DRAFT">Draft</option>
<option value="ACTIVE">Active</option>
<option value="ARCHIVED">Archived</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expires After (days)
</label>
<input
type="number"
value={formData.expires_after_days || ''}
onChange={(e) => setFormData({ ...formData, expires_after_days: e.target.value ? parseInt(e.target.value) : null })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Leave blank for no expiration"
min="1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Brief description of this template"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contract Content (HTML) *
</label>
<div className="mb-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2">
<strong>Available Variables:</strong>
</p>
<div className="flex flex-wrap gap-2 text-xs font-mono">
{['{{CUSTOMER_NAME}}', '{{CUSTOMER_EMAIL}}', '{{BUSINESS_NAME}}', '{{DATE}}', '{{YEAR}}'].map((v) => (
<span
key={v}
className="px-2 py-1 bg-white dark:bg-gray-700 rounded cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800"
onClick={() => setFormData({ ...formData, content: formData.content + v })}
>
{v}
</span>
))}
</div>
</div>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white font-mono text-sm"
rows={12}
required
placeholder="<h1>Service Agreement</h1>&#10;<p>This agreement is between {{BUSINESS_NAME}} and {{CUSTOMER_NAME}}...</p>"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createTemplate.isPending || updateTemplate.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{(createTemplate.isPending || updateTemplate.isPending) && (
<Loader2 className="w-4 h-4 animate-spin" />
)}
{editingTemplate ? 'Save Changes' : 'Create Template'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setDeleteConfirmId(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Delete Template</h3>
</div>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to delete this template? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
disabled={deleteTemplate.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{deleteTemplate.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ContractTemplates;

View File

@@ -0,0 +1,892 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileSignature, Plus, Search, Send, Eye, X, Loader2,
Clock, CheckCircle, XCircle, AlertCircle, Copy, RefreshCw, Ban,
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2,
} from 'lucide-react';
import {
useContracts,
useContractTemplates,
useCreateContract,
useSendContract,
useVoidContract,
useResendContract,
useCreateContractTemplate,
useUpdateContractTemplate,
useDeleteContractTemplate,
} from '../hooks/useContracts';
import { useCustomers } from '../hooks/useCustomers';
import apiClient from '../api/client';
import { Contract, ContractTemplate, Customer, ContractTemplateStatus, ContractScope } from '../types';
type ContractStatus = 'PENDING' | 'SIGNED' | 'EXPIRED' | 'VOIDED';
interface TemplateFormData {
name: string;
description: string;
content: string;
scope: ContractScope;
status: ContractTemplateStatus;
expires_after_days: number | null;
}
const Contracts: React.FC = () => {
const { t } = useTranslation();
// Section collapse state
const [templatesExpanded, setTemplatesExpanded] = useState(true);
const [contractsExpanded, setContractsExpanded] = useState(true);
// Contract list state
const [contractsTab, setContractsTab] = useState<ContractStatus | 'all'>('all');
const [contractsSearch, setContractsSearch] = useState('');
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
const [viewingContract, setViewingContract] = useState<Contract | null>(null);
const [voidingContract, setVoidingContract] = useState<Contract | null>(null);
const [voidReason, setVoidReason] = useState('');
// Create contract form state
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
const [selectedCustomerId, setSelectedCustomerId] = useState<string>('');
const [selectedCustomerName, setSelectedCustomerName] = useState<string>('');
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
const [isCustomerDropdownOpen, setIsCustomerDropdownOpen] = useState(false);
const [sendEmailOnCreate, setSendEmailOnCreate] = useState(true);
const customerDropdownRef = useRef<HTMLDivElement>(null);
// Template state
const [templatesTab, setTemplatesTab] = useState<ContractTemplateStatus | 'all'>('all');
const [templatesSearch, setTemplatesSearch] = useState('');
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [templateFormData, setTemplateFormData] = useState<TemplateFormData>({
name: '',
description: '',
content: '',
scope: 'APPOINTMENT',
status: 'DRAFT',
expires_after_days: null,
});
// Data hooks
const { data: contracts, isLoading: contractsLoading } = useContracts();
const { data: allTemplates, isLoading: templatesLoading } = useContractTemplates();
const { data: activeTemplates } = useContractTemplates('ACTIVE');
const { data: customers = [], isLoading: customersLoading, error: customersError } = useCustomers();
// Contract mutations
const createContract = useCreateContract();
const sendContract = useSendContract();
const voidContract = useVoidContract();
const resendContract = useResendContract();
// Template mutations
const createTemplate = useCreateContractTemplate();
const updateTemplate = useUpdateContractTemplate();
const deleteTemplate = useDeleteContractTemplate();
// Filter customers based on search
const filteredCustomers = useMemo(() => {
if (!customers) return [];
if (!customerSearchTerm) return customers;
const term = customerSearchTerm.toLowerCase();
return customers.filter(
(c) => c.name?.toLowerCase().includes(term) || c.email?.toLowerCase().includes(term)
);
}, [customers, customerSearchTerm]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (customerDropdownRef.current && !customerDropdownRef.current.contains(event.target as Node)) {
setIsCustomerDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter contracts
const filteredContracts = useMemo(() => {
if (!contracts) return [];
return contracts.filter((contract) => {
const matchesTab = contractsTab === 'all' || contract.status === contractsTab;
const matchesSearch =
contract.customer_name?.toLowerCase().includes(contractsSearch.toLowerCase()) ||
contract.customer_email?.toLowerCase().includes(contractsSearch.toLowerCase()) ||
contract.template_name?.toLowerCase().includes(contractsSearch.toLowerCase());
return matchesTab && matchesSearch;
});
}, [contracts, contractsTab, contractsSearch]);
// Count contracts by status
const contractStatusCounts = useMemo(() => {
if (!contracts) return { all: 0, PENDING: 0, SIGNED: 0, EXPIRED: 0, VOIDED: 0 };
return {
all: contracts.length,
PENDING: contracts.filter((c) => c.status === 'PENDING').length,
SIGNED: contracts.filter((c) => c.status === 'SIGNED').length,
EXPIRED: contracts.filter((c) => c.status === 'EXPIRED').length,
VOIDED: contracts.filter((c) => c.status === 'VOIDED').length,
};
}, [contracts]);
// Filter templates
const filteredTemplates = useMemo(() => {
if (!allTemplates) return [];
return allTemplates.filter((template) => {
const matchesTab = templatesTab === 'all' || template.status === templatesTab;
const matchesSearch = template.name.toLowerCase().includes(templatesSearch.toLowerCase()) ||
template.description.toLowerCase().includes(templatesSearch.toLowerCase());
return matchesTab && matchesSearch;
});
}, [allTemplates, templatesTab, templatesSearch]);
// Count templates by status
const templateStatusCounts = useMemo(() => {
if (!allTemplates) return { all: 0, ACTIVE: 0, DRAFT: 0, ARCHIVED: 0 };
return {
all: allTemplates.length,
ACTIVE: allTemplates.filter((t) => t.status === 'ACTIVE').length,
DRAFT: allTemplates.filter((t) => t.status === 'DRAFT').length,
ARCHIVED: allTemplates.filter((t) => t.status === 'ARCHIVED').length,
};
}, [allTemplates]);
// Handlers
const handleSelectCustomer = (customer: Customer) => {
setSelectedCustomerId(customer.userId || customer.id);
setSelectedCustomerName(`${customer.name} (${customer.email})`);
setCustomerSearchTerm('');
setIsCustomerDropdownOpen(false);
};
const clearCustomerSelection = () => {
setSelectedCustomerId('');
setSelectedCustomerName('');
setCustomerSearchTerm('');
};
const handleCreateContract = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTemplateId || !selectedCustomerId) return;
try {
const result = await createContract.mutateAsync({
template: selectedTemplateId,
customer: selectedCustomerId,
});
if (sendEmailOnCreate && result.id) {
await sendContract.mutateAsync(String(result.id));
}
setIsCreateContractModalOpen(false);
setSelectedTemplateId('');
setSelectedCustomerId('');
setSelectedCustomerName('');
setCustomerSearchTerm('');
setSendEmailOnCreate(true);
} catch (error) {
console.error('Failed to create contract:', error);
}
};
const handleSendContract = async (id: string) => {
try {
await sendContract.mutateAsync(id);
} catch (error) {
console.error('Failed to send contract:', error);
}
};
const handleResendContract = async (id: string) => {
try {
await resendContract.mutateAsync(id);
} catch (error) {
console.error('Failed to resend contract:', error);
}
};
const handleVoidContract = async () => {
if (!voidingContract || !voidReason.trim()) return;
try {
await voidContract.mutateAsync({
id: voidingContract.id,
reason: voidReason,
});
setVoidingContract(null);
setVoidReason('');
} catch (error) {
console.error('Failed to void contract:', error);
}
};
const copySigningLink = (contract: Contract) => {
if (contract.public_token) {
const link = `${window.location.origin}/sign/${contract.public_token}`;
navigator.clipboard.writeText(link);
}
};
// Template handlers
const resetTemplateForm = () => {
setTemplateFormData({
name: '',
description: '',
content: '',
scope: 'APPOINTMENT',
status: 'DRAFT',
expires_after_days: null,
});
setEditingTemplate(null);
};
const openCreateTemplateModal = () => {
resetTemplateForm();
setIsTemplateModalOpen(true);
};
const openEditTemplateModal = (template: ContractTemplate) => {
setEditingTemplate(template);
setTemplateFormData({
name: template.name,
description: template.description,
content: template.content,
scope: template.scope,
status: template.status,
expires_after_days: template.expires_after_days,
});
setIsTemplateModalOpen(true);
};
const handleTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTemplate) {
await updateTemplate.mutateAsync({
id: editingTemplate.id,
updates: templateFormData,
});
} else {
await createTemplate.mutateAsync(templateFormData);
}
setIsTemplateModalOpen(false);
resetTemplateForm();
} catch (error) {
console.error('Failed to save template:', error);
}
};
const handleDeleteTemplate = async (id: string) => {
try {
await deleteTemplate.mutateAsync(id);
setDeleteConfirmId(null);
} catch (error) {
console.error('Failed to delete template:', error);
}
};
// Badge renderers
const getContractStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; icon: React.ReactNode }> = {
PENDING: { bg: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', icon: <Clock size={14} /> },
SIGNED: { bg: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', icon: <CheckCircle size={14} /> },
EXPIRED: { bg: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400', icon: <XCircle size={14} /> },
VOIDED: { bg: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', icon: <Ban size={14} /> },
};
const style = styles[status] || styles.PENDING;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${style.bg}`}>
{style.icon}
{t(`contracts.status.${status.toLowerCase()}`)}
</span>
);
};
const getTemplateStatusBadge = (status: ContractTemplateStatus) => {
const styles = {
ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
DRAFT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
ARCHIVED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
{t(`contracts.status.${status.toLowerCase()}`)}
</span>
);
};
const getScopeBadge = (scope: ContractScope) => {
const styles = {
CUSTOMER: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
APPOINTMENT: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[scope]}`}>
{scope === 'CUSTOMER' ? 'Customer-Level' : 'Per Appointment'}
</span>
);
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (contractsLoading && templatesLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
return (
<div className="p-8 space-y-6">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<FileSignature className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold dark:text-white">{t('contracts.title')}</h1>
</div>
<p className="text-gray-600 dark:text-gray-400">{t('contracts.description')}</p>
</div>
{/* Templates Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<button
onClick={() => setTemplatesExpanded(!templatesExpanded)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-3">
{templatesExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('contracts.templates')}
</h2>
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
{allTemplates?.length || 0}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
openCreateTemplateModal();
}}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Plus size={16} />
New Template
</button>
</button>
{templatesExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
{/* Template Search and Tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
value={templatesSearch}
onChange={(e) => setTemplatesSearch(e.target.value)}
placeholder="Search templates..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
<div className="flex gap-2">
{(['all', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((tab) => (
<button
key={tab}
onClick={() => setTemplatesTab(tab)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
templatesTab === tab
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
}`}
>
{tab === 'all' ? 'All' : tab.charAt(0) + tab.slice(1).toLowerCase()}
<span className="ml-1 text-xs">({templateStatusCounts[tab]})</span>
</button>
))}
</div>
</div>
{/* Templates Table */}
{filteredTemplates.length === 0 ? (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
<FileSignature className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p>{templatesSearch ? 'No templates found' : 'No templates yet. Create your first template to get started.'}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Template</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scope</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Version</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredTemplates.map((template) => (
<tr key={template.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{template.name}</div>
{template.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">{template.description}</div>
)}
</td>
<td className="px-4 py-3">{getScopeBadge(template.scope)}</td>
<td className="px-4 py-3">{getTemplateStatusBadge(template.status)}</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">v{template.version}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={async () => {
try {
const response = await apiClient.get(`/contracts/templates/${template.id}/preview_pdf/`, { responseType: 'blob' });
const blob = new Blob([response.data], { type: 'application/pdf' });
window.open(URL.createObjectURL(blob), '_blank');
} catch (error) {
console.error('Failed to load PDF preview:', error);
alert('Failed to load PDF preview.');
}
}}
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Preview PDF"
>
<Eye size={16} />
</button>
<button
onClick={() => openEditTemplateModal(template)}
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => setDeleteConfirmId(template.id)}
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* Contracts Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<button
onClick={() => setContractsExpanded(!contractsExpanded)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-3">
{contractsExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Sent Contracts
</h2>
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
{contracts?.length || 0}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsCreateContractModalOpen(true);
}}
disabled={!activeTemplates || activeTemplates.length === 0}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} />
Send Contract
</button>
</button>
{contractsExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
{/* Contract Search and Tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
value={contractsSearch}
onChange={(e) => setContractsSearch(e.target.value)}
placeholder="Search contracts..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
<div className="flex gap-2 flex-wrap">
{(['all', 'PENDING', 'SIGNED', 'EXPIRED', 'VOIDED'] as const).map((tab) => (
<button
key={tab}
onClick={() => setContractsTab(tab)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
contractsTab === tab
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
}`}
>
{tab === 'all' ? 'All' : tab.charAt(0) + tab.slice(1).toLowerCase()}
<span className="ml-1 text-xs">({contractStatusCounts[tab]})</span>
</button>
))}
</div>
</div>
{/* Contracts Table */}
{filteredContracts.length === 0 ? (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
<FileSignature className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p>{contractsSearch ? 'No contracts found' : 'No contracts sent yet.'}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Contract</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Created</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredContracts.map((contract) => (
<tr key={contract.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{contract.customer_name || 'Unknown'}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{contract.customer_email}</div>
</td>
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{contract.template_name}</div>
{contract.sent_at && (
<div className="text-xs text-gray-500 dark:text-gray-400">Sent: {formatDate(contract.sent_at)}</div>
)}
</td>
<td className="px-4 py-3">
{getContractStatusBadge(contract.status)}
{contract.signed_at && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Signed: {formatDate(contract.signed_at)}</div>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{formatDate(contract.created_at)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setViewingContract(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="View Details">
<Eye size={16} />
</button>
{contract.status === 'PENDING' && (
<>
<button onClick={() => copySigningLink(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Copy Signing Link">
<Copy size={16} />
</button>
{!contract.sent_at ? (
<button onClick={() => handleSendContract(contract.id)} disabled={sendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Send Email">
<Send size={16} />
</button>
) : (
<button onClick={() => handleResendContract(contract.id)} disabled={resendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Resend Email">
<RefreshCw size={16} />
</button>
)}
<button onClick={() => setVoidingContract(contract)} className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Void Contract">
<Ban size={16} />
</button>
</>
)}
{contract.public_token && contract.status === 'PENDING' && (
<a href={`/sign/${contract.public_token}`} target="_blank" rel="noopener noreferrer" className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Open Signing Page">
<ExternalLink size={16} />
</a>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* Create Contract Modal */}
{isCreateContractModalOpen && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsCreateContractModalOpen(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Send Contract</h2>
<button onClick={() => setIsCreateContractModalOpen(false)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<X size={20} />
</button>
</div>
<form onSubmit={handleCreateContract} className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contract Template *</label>
<select value={selectedTemplateId} onChange={(e) => setSelectedTemplateId(e.target.value)} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
<option value="">Select a template...</option>
{activeTemplates?.map((template) => (
<option key={template.id} value={template.id}>{template.name} (v{template.version})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer *</label>
<div className="relative" ref={customerDropdownRef}>
{selectedCustomerId ? (
<div className="flex items-center gap-2 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700">
<User size={16} className="text-gray-400" />
<span className="flex-1 text-gray-900 dark:text-white truncate">{selectedCustomerName}</span>
<button type="button" onClick={clearCustomerSelection} className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X size={16} />
</button>
</div>
) : (
<>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="text" value={customerSearchTerm} onChange={(e) => { setCustomerSearchTerm(e.target.value); setIsCustomerDropdownOpen(true); }} onFocus={() => setIsCustomerDropdownOpen(true)} placeholder="Search customers..." className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" />
<ChevronDown size={16} className={`absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-transform ${isCustomerDropdownOpen ? 'rotate-180' : ''}`} />
</div>
{isCustomerDropdownOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{customersLoading ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
Loading customers...
</div>
) : customersError ? (
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
Failed to load customers
</div>
) : filteredCustomers.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{customers.length === 0 ? 'No customers available. Create customers first.' : 'No matching customers'}
</div>
) : (
filteredCustomers.map((customer) => (
<button key={customer.id} type="button" onClick={() => handleSelectCustomer(customer)} className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
<User size={14} className="text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-900 dark:text-white truncate">{customer.name || 'Unnamed'}</div>
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">{customer.email}</div>
</div>
</button>
))
)}
</div>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="sendEmail" checked={sendEmailOnCreate} onChange={(e) => setSendEmailOnCreate(e.target.checked)} className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
<label htmlFor="sendEmail" className="text-sm text-gray-700 dark:text-gray-300">Send signing request email immediately</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" onClick={() => setIsCreateContractModalOpen(false)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button type="submit" disabled={createContract.isPending || !selectedTemplateId || !selectedCustomerId} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
{createContract.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Send Contract
</button>
</div>
</form>
</div>
</div>
)}
{/* View Contract Modal */}
{viewingContract && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setViewingContract(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Contract Details</h2>
<button onClick={() => setViewingContract(null)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Customer</p>
<p className="font-medium text-gray-900 dark:text-white">{viewingContract.customer_name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{viewingContract.customer_email}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Template</p>
<p className="font-medium text-gray-900 dark:text-white">{viewingContract.template_name} (v{viewingContract.template_version})</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
{getContractStatusBadge(viewingContract.status)}
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Created</p>
<p className="text-gray-900 dark:text-white">{formatDate(viewingContract.created_at)}</p>
</div>
</div>
{viewingContract.content && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">Content Preview</p>
<div className="prose dark:prose-invert max-w-none p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: viewingContract.content }} />
</div>
)}
{viewingContract.public_token && viewingContract.status === 'PENDING' && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">Signing Link</p>
<div className="flex items-center gap-2">
<input type="text" readOnly value={`${window.location.origin}/sign/${viewingContract.public_token}`} className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 rounded" />
<button onClick={() => copySigningLink(viewingContract)} className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<Copy size={16} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Void Contract Modal */}
{voidingContract && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setVoidingContract(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Void Contract</h3>
</div>
<p className="text-gray-600 dark:text-gray-400 mb-4">Voiding this contract will cancel it. The customer will no longer be able to sign.</p>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason for voiding *</label>
<textarea value={voidReason} onChange={(e) => setVoidReason(e.target.value)} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent dark:bg-gray-700 dark:text-white" rows={3} placeholder="Enter the reason..." required />
</div>
<div className="flex justify-end gap-3">
<button onClick={() => { setVoidingContract(null); setVoidReason(''); }} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button onClick={handleVoidContract} disabled={voidContract.isPending || !voidReason.trim()} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2">
{voidContract.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Void Contract
</button>
</div>
</div>
</div>
)}
{/* Create/Edit Template Modal */}
{isTemplateModalOpen && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsTemplateModalOpen(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2>
<button onClick={() => setIsTemplateModalOpen(false)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<X size={20} />
</button>
</div>
<form onSubmit={handleTemplateSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Template Name *</label>
<input type="text" value={templateFormData.name} onChange={(e) => setTemplateFormData({ ...templateFormData, name: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Scope *</label>
<select value={templateFormData.scope} onChange={(e) => setTemplateFormData({ ...templateFormData, scope: e.target.value as ContractScope })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="APPOINTMENT">Per Appointment (e.g., liability waivers)</option>
<option value="CUSTOMER">Customer-Level (e.g., terms of service)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select value={templateFormData.status} onChange={(e) => setTemplateFormData({ ...templateFormData, status: e.target.value as ContractTemplateStatus })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="DRAFT">Draft</option>
<option value="ACTIVE">Active</option>
<option value="ARCHIVED">Archived</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expires After (days)</label>
<input type="number" value={templateFormData.expires_after_days || ''} onChange={(e) => setTemplateFormData({ ...templateFormData, expires_after_days: e.target.value ? parseInt(e.target.value) : null })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" placeholder="Leave blank for no expiration" min="1" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<input type="text" value={templateFormData.description} onChange={(e) => setTemplateFormData({ ...templateFormData, description: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" placeholder="Brief description of this template" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contract Content (HTML) *</label>
<div className="mb-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2"><strong>Available Variables:</strong></p>
<div className="flex flex-wrap gap-2 text-xs font-mono">
{['{{CUSTOMER_NAME}}', '{{CUSTOMER_EMAIL}}', '{{BUSINESS_NAME}}', '{{DATE}}', '{{YEAR}}'].map((v) => (
<span key={v} className="px-2 py-1 bg-white dark:bg-gray-700 rounded cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800" onClick={() => setTemplateFormData({ ...templateFormData, content: templateFormData.content + v })}>{v}</span>
))}
</div>
</div>
<textarea value={templateFormData.content} onChange={(e) => setTemplateFormData({ ...templateFormData, content: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white font-mono text-sm" rows={12} required placeholder="<h1>Service Agreement</h1>&#10;<p>This agreement is between {{BUSINESS_NAME}} and {{CUSTOMER_NAME}}...</p>" />
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" onClick={() => setIsTemplateModalOpen(false)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button type="submit" disabled={createTemplate.isPending || updateTemplate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
{(createTemplate.isPending || updateTemplate.isPending) && <Loader2 className="w-4 h-4 animate-spin" />}
{editingTemplate ? 'Save Changes' : 'Create Template'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Template Confirmation Modal */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setDeleteConfirmId(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Delete Template</h3>
</div>
<p className="text-gray-600 dark:text-gray-400 mb-6">Are you sure you want to delete this template? This action cannot be undone.</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirmId(null)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button onClick={() => handleDeleteTemplate(deleteConfirmId)} disabled={deleteTemplate.isPending} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2">
{deleteTemplate.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Contracts;

View File

@@ -11,6 +11,7 @@ import {
BookOpen,
LayoutDashboard,
Calendar,
CalendarOff,
CheckSquare,
Users,
Briefcase,
@@ -56,6 +57,7 @@ const HelpGuide: React.FC = () => {
{ label: 'Services', path: '/help/services', icon: <Briefcase size={18} /> },
{ label: 'Resources', path: '/help/resources', icon: <ClipboardList size={18} /> },
{ label: 'Staff', path: '/help/staff', icon: <UserCog size={18} /> },
{ label: 'Time Blocks', path: '/help/time-blocks', icon: <CalendarOff size={18} /> },
],
},
{

View File

@@ -0,0 +1,499 @@
/**
* Help Time Blocks - Comprehensive guide for time blocking features
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft,
CalendarOff,
Building2,
User,
Clock,
CalendarDays,
Ban,
AlertCircle,
CheckCircle,
Info,
Repeat,
ChevronRight,
BookOpen,
Lightbulb,
AlertTriangle,
Eye,
} from 'lucide-react';
const HelpTimeBlocks: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="max-w-4xl mx-auto py-8 px-4">
{/* Back Button */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6"
>
<ArrowLeft size={20} />
{t('common.back', 'Back')}
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('helpTimeBlocks.title', 'Time Blocks Guide')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('helpTimeBlocks.subtitle', 'Learn how to block off time for closures, holidays, and unavailability')}
</p>
</div>
</div>
</div>
{/* Overview Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CalendarOff size={20} className="text-brand-500" />
{t('helpTimeBlocks.overview.title', 'What are Time Blocks?')}
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Time blocks allow you to mark specific dates, times, or recurring periods as unavailable for bookings.
Use them to manage holidays, business closures, staff vacations, maintenance windows, and more.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Building2 size={20} className="text-blue-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Business Blocks</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Apply to all resources. Perfect for company holidays, office closures, and maintenance.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<User size={20} className="text-green-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Resource Blocks</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Apply to specific resources. Use for individual vacations, appointments, or training.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Ban size={20} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Hard Blocks</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Completely prevent bookings during the blocked period. Cannot be overridden.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<AlertCircle size={20} className="text-yellow-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Soft Blocks</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Show a warning but still allow bookings with confirmation.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Block Levels Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Building2 size={20} className="text-brand-500" />
{t('helpTimeBlocks.levels.title', 'Block Levels')}
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scope</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Example Uses</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Building2 size={16} className="text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">Business</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
All resources in your business
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
Holidays, office closures, company events, maintenance
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<User size={16} className="text-green-500" />
<span className="font-medium text-gray-900 dark:text-white">Resource</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
A specific resource (staff member, room, etc.)
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
Vacation, personal appointments, lunch breaks, training
</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<Info size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-300">Blocks are Additive</h4>
<p className="text-sm text-blue-700 dark:text-blue-400">
Both business-level and resource-level blocks apply. If the business is closed on a holiday,
individual resource blocks don't matter for that day.
</p>
</div>
</div>
</div>
</section>
{/* Block Types Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Ban size={20} className="text-brand-500" />
{t('helpTimeBlocks.types.title', 'Block Types: Hard vs Soft')}
</h2>
<div className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<Ban size={24} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Hard Block</h3>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Completely prevents any bookings during the blocked period. Customers cannot book,
and staff cannot override. The scheduler shows a striped red overlay.
</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 text-xs rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">Cannot be overridden</span>
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Shows in customer booking</span>
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Red striped overlay</span>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center flex-shrink-0">
<AlertCircle size={24} className="text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Soft Block</h3>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Shows a warning but allows bookings with confirmation. Useful for indicating
preferred-off times that can be overridden if necessary.
</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 text-xs rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300">Can be overridden</span>
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Shows warning only</span>
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Yellow dashed overlay</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Recurrence Patterns Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Repeat size={20} className="text-brand-500" />
{t('helpTimeBlocks.recurrence.title', 'Recurrence Patterns')}
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pattern</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Example</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">One-time</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
A specific date or date range that occurs once
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
Dec 24-26 (Christmas break), Feb 15 (President's Day)
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">Weekly</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Repeats on specific days of the week
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
Every Saturday and Sunday, Every Monday lunch
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">Monthly</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Repeats on specific days of the month
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
1st of every month (inventory), 15th (payroll)
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">Yearly</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Repeats on a specific month and day each year
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
July 4th, December 25th, January 1st
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Holiday</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Select from popular US holidays. Multi-select supported - each holiday creates its own block.
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
Christmas, Thanksgiving, Memorial Day, Independence Day
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Visualization Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Eye size={20} className="text-brand-500" />
{t('helpTimeBlocks.visualization.title', 'Viewing Time Blocks')}
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-6">
Time blocks appear in multiple views throughout the application with color-coded indicators:
</p>
{/* Color Legend */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Color Legend</h4>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-4 rounded" style={{ background: 'repeating-linear-gradient(-45deg, rgba(239, 68, 68, 0.3), rgba(239, 68, 68, 0.3) 3px, rgba(239, 68, 68, 0.5) 3px, rgba(239, 68, 68, 0.5) 6px)' }}></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Business Hard Block</span>
<span className="px-1.5 py-0.5 text-[10px] bg-red-500 text-white rounded font-semibold">B</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-4 rounded bg-yellow-200 dark:bg-yellow-900/50 border border-dashed border-yellow-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Business Soft Block</span>
<span className="px-1.5 py-0.5 text-[10px] bg-yellow-500 text-white rounded font-semibold">B</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-4 rounded" style={{ background: 'repeating-linear-gradient(-45deg, rgba(147, 51, 234, 0.25), rgba(147, 51, 234, 0.25) 3px, rgba(147, 51, 234, 0.4) 3px, rgba(147, 51, 234, 0.4) 6px)' }}></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Resource Hard Block</span>
<span className="px-1.5 py-0.5 text-[10px] bg-purple-500 text-white rounded font-semibold">R</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-4 rounded bg-cyan-200 dark:bg-cyan-900/50 border border-dashed border-cyan-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Resource Soft Block</span>
<span className="px-1.5 py-0.5 text-[10px] bg-cyan-500 text-white rounded font-semibold">R</span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CalendarDays size={20} className="text-brand-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Scheduler Overlay</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Blocked times appear directly on the scheduler calendar with visual indicators.
Business blocks use red/yellow colors, resource blocks use purple/cyan.
Click on any blocked area in week view to navigate to that day.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CalendarOff size={20} className="text-brand-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Month View</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Blocked dates show with colored backgrounds and badge indicators.
Multiple block types on the same day show all applicable badges.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Clock size={20} className="text-brand-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">List View</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Manage all time blocks in a tabular format with filtering options.
Edit, activate/deactivate, or delete blocks from here.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Staff Availability Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<User size={20} className="text-brand-500" />
{t('helpTimeBlocks.staffAvailability.title', 'Staff Availability (My Availability)')}
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Staff members can manage their own time blocks through the "My Availability" page.
This allows them to block off time for personal appointments, vacations, or other commitments.
</p>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">View business-level blocks (read-only)</span>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">Create and manage personal time blocks</span>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">See yearly calendar of their availability</span>
</div>
</div>
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<AlertTriangle size={20} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-300">Hard Block Permission</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-400">
By default, staff can only create soft blocks. To allow a staff member to create hard blocks,
enable the "Can create hard blocks" permission in their staff settings.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Best Practices Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Lightbulb size={20} className="text-brand-500" />
{t('helpTimeBlocks.bestPractices.title', 'Best Practices')}
</h2>
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
<ul className="space-y-4">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">1</span>
<div>
<span className="font-medium text-gray-900 dark:text-white">Plan holidays in advance</span>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Set up annual holidays at the beginning of each year using the Holiday recurrence type.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">2</span>
<div>
<span className="font-medium text-gray-900 dark:text-white">Use soft blocks for preferences</span>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Reserve hard blocks for absolute closures. Use soft blocks for preferred-off times that could be overridden.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">3</span>
<div>
<span className="font-medium text-gray-900 dark:text-white">Check for conflicts before creating</span>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
The system shows existing appointments that conflict with new blocks. Review before confirming.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">4</span>
<div>
<span className="font-medium text-gray-900 dark:text-white">Set recurrence end dates</span>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
For recurring blocks that aren't permanent, set an end date to prevent them from extending indefinitely.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">5</span>
<div>
<span className="font-medium text-gray-900 dark:text-white">Use descriptive titles</span>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Include clear titles like "Christmas Day", "Team Meeting", or "Annual Maintenance" for easy identification.
</p>
</div>
</li>
</ul>
</div>
</section>
{/* Quick Access Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
{t('helpTimeBlocks.quickAccess.title', 'Quick Access')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<a
href="/time-blocks"
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-500 transition-colors"
>
<div className="flex items-center gap-3">
<Building2 size={20} className="text-brand-500" />
<span className="font-medium text-gray-900 dark:text-white">Manage Time Blocks</span>
</div>
<ChevronRight size={16} className="text-gray-400" />
</a>
<a
href="/my-availability"
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-500 transition-colors"
>
<div className="flex items-center gap-3">
<User size={20} className="text-brand-500" />
<span className="font-medium text-gray-900 dark:text-white">My Availability</span>
</div>
<ChevronRight size={16} className="text-gray-400" />
</a>
</div>
</section>
</div>
);
};
export default HelpTimeBlocks;

View File

@@ -0,0 +1,789 @@
/**
* My Availability Page
*
* Staff-facing page to view and manage their own time blocks.
* Shows business-level blocks (read-only) and personal blocks (editable).
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
TimeBlockListItem,
BlockType,
RecurrenceType,
RecurrencePattern,
User,
} from '../types';
import {
useMyBlocks,
useCreateTimeBlock,
useUpdateTimeBlock,
useDeleteTimeBlock,
useToggleTimeBlock,
useHolidays,
CreateTimeBlockData,
} from '../hooks/useTimeBlocks';
import Portal from '../components/Portal';
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
import {
Calendar,
Building2,
User as UserIcon,
Plus,
Pencil,
Trash2,
X,
AlertTriangle,
Clock,
CalendarDays,
Ban,
AlertCircle,
Power,
PowerOff,
Info,
} from 'lucide-react';
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
NONE: 'One-time',
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
YEARLY: 'Yearly',
HOLIDAY: 'Holiday',
};
const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
HARD: 'Hard Block',
SOFT: 'Soft Block',
};
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
interface TimeBlockFormData {
title: string;
description: string;
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;
}
const defaultFormData: TimeBlockFormData = {
title: '',
description: '',
block_type: 'SOFT',
recurrence_type: 'NONE',
start_date: '',
end_date: '',
all_day: true,
start_time: '09:00',
end_time: '17:00',
recurrence_pattern: {},
recurrence_start: '',
recurrence_end: '',
};
interface MyAvailabilityProps {
user?: User;
}
const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
const { t } = useTranslation();
const contextUser = useOutletContext<{ user?: User }>()?.user;
const user = props.user || contextUser;
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Fetch data
const { data: myBlocksData, isLoading } = useMyBlocks();
const { data: holidays = [] } = useHolidays('US');
// Mutations
const createBlock = useCreateTimeBlock();
const updateBlock = useUpdateTimeBlock();
const deleteBlock = useDeleteTimeBlock();
const toggleBlock = useToggleTimeBlock();
// Check if user can create hard blocks
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
// Modal handlers
const openCreateModal = () => {
setEditingBlock(null);
setFormData(defaultFormData);
setIsModalOpen(true);
};
const openEditModal = (block: TimeBlockListItem) => {
setEditingBlock(block);
setFormData({
title: block.title,
description: '',
block_type: block.block_type,
recurrence_type: block.recurrence_type,
start_date: '',
end_date: '',
all_day: true,
start_time: '09:00',
end_time: '17:00',
recurrence_pattern: {},
recurrence_start: '',
recurrence_end: '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingBlock(null);
setFormData(defaultFormData);
};
// Form handlers
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
setFormData((prev) => ({
...prev,
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
}));
};
const handleDayOfWeekToggle = (day: number) => {
const current = formData.recurrence_pattern.days_of_week || [];
const newDays = current.includes(day)
? current.filter((d) => d !== day)
: [...current, day].sort();
handlePatternChange('days_of_week', newDays);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!myBlocksData?.resource_id) {
console.error('No resource linked to user');
return;
}
const payload: CreateTimeBlockData = {
title: formData.title,
description: formData.description || undefined,
resource: myBlocksData.resource_id,
block_type: formData.block_type,
recurrence_type: formData.recurrence_type,
all_day: formData.all_day,
};
// Add type-specific fields
if (formData.recurrence_type === 'NONE') {
payload.start_date = formData.start_date;
payload.end_date = formData.end_date || formData.start_date;
}
if (!formData.all_day) {
payload.start_time = formData.start_time;
payload.end_time = formData.end_time;
}
if (formData.recurrence_type !== 'NONE') {
payload.recurrence_pattern = formData.recurrence_pattern;
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
}
try {
if (editingBlock) {
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
} else {
await createBlock.mutateAsync(payload);
}
closeModal();
} catch (error) {
console.error('Failed to save time block:', error);
}
};
const handleDelete = async (id: string) => {
try {
await deleteBlock.mutateAsync(id);
setDeleteConfirmId(null);
} catch (error) {
console.error('Failed to delete time block:', error);
}
};
const handleToggle = async (id: string) => {
try {
await toggleBlock.mutateAsync(id);
} catch (error) {
console.error('Failed to toggle time block:', error);
}
};
// Render block type badge
const renderBlockTypeBadge = (type: BlockType) => (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
type === 'HARD'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}
>
{type === 'HARD' ? <Ban size={12} className="mr-1" /> : <AlertCircle size={12} className="mr-1" />}
{BLOCK_TYPE_LABELS[type]}
</span>
);
// Render recurrence badge
const renderRecurrenceBadge = (type: RecurrenceType) => (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{type === 'HOLIDAY' ? (
<CalendarDays size={12} className="mr-1" />
) : type === 'NONE' ? (
<Clock size={12} className="mr-1" />
) : (
<Calendar size={12} className="mr-1" />
)}
{RECURRENCE_TYPE_LABELS[type]}
</span>
);
// Handle no linked resource
if (!isLoading && !myBlocksData?.resource_id) {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('myAvailability.title', 'My Availability')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
</p>
</div>
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<AlertTriangle size={48} className="mx-auto text-yellow-500 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('myAvailability.noResource', 'No Resource Linked')}
</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
{t('myAvailability.noResourceDesc', 'Your account is not linked to a resource. Please contact your manager to set up your availability.')}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('myAvailability.title', 'My Availability')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{myBlocksData?.resource_name && (
<span className="flex items-center gap-1">
<UserIcon size={14} />
{myBlocksData.resource_name}
</span>
)}
</p>
</div>
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
<Plus size={18} />
{t('myAvailability.addBlock', 'Block Time')}
</button>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
</div>
) : (
<div className="space-y-6">
{/* Business Blocks (Read-only) */}
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Building2 size={20} />
{t('myAvailability.businessBlocks', 'Business Closures')}
</h2>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
<Info size={16} />
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{myBlocksData.business_blocks.map((block) => (
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-medium text-gray-900 dark:text-white">
{block.title}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderBlockTypeBadge(block.block_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{renderRecurrenceBadge(block.recurrence_type)}
{block.pattern_display && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{block.pattern_display}
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* My Blocks (Editable) */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<UserIcon size={20} />
{t('myAvailability.myBlocks', 'My Time Blocks')}
</h2>
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('myAvailability.noBlocks', 'No Time Blocks')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
</p>
<button onClick={openCreateModal} className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
<Plus size={18} />
{t('myAvailability.addFirstBlock', 'Add First Block')}
</button>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('myAvailability.titleCol', 'Title')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('myAvailability.typeCol', 'Type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('myAvailability.patternCol', 'Pattern')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('myAvailability.actionsCol', 'Actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{myBlocksData?.my_blocks.map((block) => (
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-medium text-gray-900 dark:text-white">
{block.title}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderBlockTypeBadge(block.block_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{renderRecurrenceBadge(block.recurrence_type)}
{block.pattern_display && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{block.pattern_display}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggle(block.id)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={block.is_active ? 'Deactivate' : 'Activate'}
>
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
</button>
<button
onClick={() => openEditModal(block)}
className="p-2 text-gray-400 hover:text-blue-600"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => setDeleteConfirmId(block.id)}
className="p-2 text-gray-400 hover:text-red-600"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Yearly Calendar View */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<YearlyBlockCalendar
resourceId={myBlocksData?.resource_id}
onBlockClick={(blockId) => {
// Find the block and open edit modal if it's my block
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
if (block) {
openEditModal(block);
}
}}
/>
</div>
</div>
)}
{/* Create/Edit Modal */}
{isModalOpen && (
<Portal>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{editingBlock
? t('myAvailability.editBlock', 'Edit Time Block')
: t('myAvailability.createBlock', 'Block Time Off')}
</h2>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('myAvailability.form.title', 'Title')} *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => handleFormChange('title', e.target.value)}
className="input-primary w-full"
placeholder="e.g., Vacation, Lunch Break, Doctor Appointment"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('myAvailability.form.description', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
className="input-primary w-full"
rows={2}
placeholder="Optional reason"
/>
</div>
{/* Block Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('myAvailability.form.blockType', 'Block Type')}
</label>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="block_type"
value="SOFT"
checked={formData.block_type === 'SOFT'}
onChange={() => handleFormChange('block_type', 'SOFT')}
className="text-brand-500"
/>
<AlertCircle size={16} className="text-yellow-500" />
<span className="text-sm">Soft Block</span>
<span className="text-xs text-gray-500">(shows warning, can be overridden)</span>
</label>
<label className={`flex items-center gap-2 ${canCreateHardBlocks ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="radio"
name="block_type"
value="HARD"
checked={formData.block_type === 'HARD'}
onChange={() => canCreateHardBlocks && handleFormChange('block_type', 'HARD')}
className="text-brand-500"
disabled={!canCreateHardBlocks}
/>
<Ban size={16} className="text-red-500" />
<span className="text-sm">Hard Block</span>
<span className="text-xs text-gray-500">(prevents booking)</span>
{!canCreateHardBlocks && (
<span className="text-xs text-red-500">(requires permission)</span>
)}
</label>
</div>
</div>
{/* Recurrence Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('myAvailability.form.recurrenceType', 'Recurrence')}
</label>
<select
value={formData.recurrence_type}
onChange={(e) => handleFormChange('recurrence_type', e.target.value as RecurrenceType)}
className="input-primary w-full"
>
<option value="NONE">One-time (specific date/range)</option>
<option value="WEEKLY">Weekly (e.g., every Monday)</option>
<option value="MONTHLY">Monthly (e.g., 1st of month)</option>
</select>
</div>
{/* Recurrence Pattern - NONE */}
{formData.recurrence_type === 'NONE' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date *
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => handleFormChange('start_date', e.target.value)}
className="input-primary w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => handleFormChange('end_date', e.target.value)}
className="input-primary w-full"
min={formData.start_date}
/>
</div>
</div>
)}
{/* Recurrence Pattern - WEEKLY */}
{formData.recurrence_type === 'WEEKLY' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Days of Week *
</label>
<div className="flex flex-wrap gap-2">
{DAY_ABBREVS.map((day, index) => {
const isSelected = (formData.recurrence_pattern.days_of_week || []).includes(index);
return (
<button
key={day}
type="button"
onClick={() => handleDayOfWeekToggle(index)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
isSelected
? 'bg-brand-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{day}
</button>
);
})}
</div>
</div>
)}
{/* Recurrence Pattern - MONTHLY */}
{formData.recurrence_type === 'MONTHLY' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Days of Month *
</label>
<div className="flex flex-wrap gap-1">
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
const isSelected = (formData.recurrence_pattern.days_of_month || []).includes(day);
return (
<button
key={day}
type="button"
onClick={() => {
const current = formData.recurrence_pattern.days_of_month || [];
const newDays = current.includes(day)
? current.filter((d) => d !== day)
: [...current, day].sort((a, b) => a - b);
handlePatternChange('days_of_month', newDays);
}}
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
isSelected
? 'bg-brand-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{day}
</button>
);
})}
</div>
</div>
)}
{/* All Day Toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="all_day"
checked={formData.all_day}
onChange={(e) => handleFormChange('all_day', e.target.checked)}
className="rounded text-brand-500"
/>
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
{t('myAvailability.form.allDay', 'All day')}
</label>
</div>
{/* Time Range (if not all day) */}
{!formData.all_day && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Time *
</label>
<input
type="time"
value={formData.start_time}
onChange={(e) => handleFormChange('start_time', e.target.value)}
className="input-primary w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Time *
</label>
<input
type="time"
value={formData.end_time}
onChange={(e) => handleFormChange('end_time', e.target.value)}
className="input-primary w-full"
required
/>
</div>
</div>
)}
{/* Submit Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" onClick={closeModal} className="btn-secondary">
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
className="btn-primary"
disabled={createBlock.isPending || updateBlock.isPending}
>
{(createBlock.isPending || updateBlock.isPending) ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.saving', 'Saving...')}
</span>
) : editingBlock ? (
t('common.save', 'Save Changes')
) : (
t('myAvailability.create', 'Block Time')
)}
</button>
</div>
</form>
</div>
</div>
</Portal>
)}
{/* Delete Confirmation Modal */}
{deleteConfirmId && (
<Portal>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('myAvailability.deleteConfirmTitle', 'Delete Time Block?')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('myAvailability.deleteConfirmDesc', 'This action cannot be undone.')}
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary">
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="btn-danger"
disabled={deleteBlock.isPending}
>
{deleteBlock.isPending ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.deleting', 'Deleting...')}
</span>
) : (
t('common.delete', 'Delete')
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default MyAvailability;

View File

@@ -9,8 +9,10 @@ import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
import { useBlockedDates } from '../hooks/useTimeBlocks';
import Portal from '../components/Portal';
import EventAutomations from '../components/EventAutomations';
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
// Time settings
@@ -83,6 +85,14 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const updateMutation = useUpdateAppointment();
const deleteMutation = useDeleteAppointment();
// Fetch blocked dates for the calendar overlay
const blockedDatesParams = useMemo(() => ({
start_date: dateRange.startDate.toISOString().split('T')[0],
end_date: dateRange.endDate.toISOString().split('T')[0],
include_business: true,
}), [dateRange]);
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);
// Calculate over-quota resources (will be auto-archived when grace period ends)
const overQuotaResourceIds = useMemo(
() => getOverQuotaResourceIds(resources as Resource[], user.quota_overages),
@@ -1239,27 +1249,71 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const displayedAppointments = dayAppointments.slice(0, 3);
const remainingCount = dayAppointments.length - 3;
// Check if this date has any blocks
const dateBlocks = date ? blockedDates.filter(b => {
// Parse date string as local date, not UTC
const [year, month, dayNum] = b.date.split('-').map(Number);
const blockDate = new Date(year, month - 1, dayNum);
blockDate.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return blockDate.getTime() === checkDate.getTime();
}) : [];
// Separate business and resource blocks
const businessBlocks = dateBlocks.filter(b => b.resource_id === null);
const hasBusinessHard = businessBlocks.some(b => b.block_type === 'HARD');
const hasBusinessSoft = businessBlocks.some(b => b.block_type === 'SOFT');
// Group resource blocks by resource - maintain resource order
const resourceBlocksByResource = resources.map(resource => {
const blocks = dateBlocks.filter(b => b.resource_id === resource.id);
return {
resource,
blocks,
hasHard: blocks.some(b => b.block_type === 'HARD'),
hasSoft: blocks.some(b => b.block_type === 'SOFT'),
};
}).filter(rb => rb.blocks.length > 0);
// Determine background color - only business blocks affect the whole cell now
const getBgClass = () => {
if (date && date.getMonth() !== viewDate.getMonth()) return 'bg-gray-100 dark:bg-gray-800/70 opacity-50';
if (hasBusinessHard) return 'bg-red-50 dark:bg-red-900/20';
if (hasBusinessSoft) return 'bg-yellow-50 dark:bg-yellow-900/20';
if (date) return 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800';
return 'bg-gray-50 dark:bg-gray-800/50';
};
return (
<div
key={index}
className={`min-h-[120px] p-2 transition-colors relative ${
date && date.getMonth() !== viewDate.getMonth()
? 'bg-gray-100 dark:bg-gray-800/70 opacity-50'
: date
? 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
: 'bg-gray-50 dark:bg-gray-800/50'
} ${monthDropTarget?.date.getTime() === date?.getTime() && date?.getMonth() === viewDate.getMonth() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
className={`min-h-[120px] p-2 transition-colors relative ${getBgClass()} ${monthDropTarget?.date.getTime() === date?.getTime() && date?.getMonth() === viewDate.getMonth() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
onClick={() => { if (date) { setViewDate(date); setViewMode('day'); } }}
onDragOver={(e) => date && handleMonthCellDragOver(e, date)}
>
{date && (
<>
<div className={`text-sm font-medium mb-1 ${
isToday
? 'w-7 h-7 flex items-center justify-center rounded-full bg-brand-500 text-white'
: 'text-gray-700 dark:text-gray-300'
}`}>
{date.getDate()}
<div className="flex items-center justify-between mb-1">
<div className={`text-sm font-medium ${
isToday
? 'w-7 h-7 flex items-center justify-center rounded-full bg-brand-500 text-white'
: 'text-gray-700 dark:text-gray-300'
}`}>
{date.getDate()}
</div>
<div className="flex gap-1">
{hasBusinessHard && (
<span className="text-[10px] px-1.5 py-0.5 bg-red-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'HARD')?.title}>
B
</span>
)}
{!hasBusinessHard && hasBusinessSoft && (
<span className="text-[10px] px-1.5 py-0.5 bg-yellow-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'SOFT')?.title}>
B
</span>
)}
</div>
</div>
<div className="space-y-1">
{displayedAppointments.map(apt => {
@@ -1291,6 +1345,50 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
)}
</div>
{/* Resource block indicators - each resource gets 1/n of the row */}
{resourceBlocksByResource.length > 0 && (
<div
className="absolute bottom-0 left-0 right-0 flex"
style={{ height: `${Math.min(20, 60 / resources.length)}px` }}
>
{resources.map((resource, resourceIndex) => {
const resourceBlockInfo = resourceBlocksByResource.find(rb => rb.resource.id === resource.id);
if (!resourceBlockInfo) {
// Empty slot for this resource
return (
<div
key={resource.id}
className="flex-1"
style={{ minWidth: 0 }}
/>
);
}
const { hasHard, blocks } = resourceBlockInfo;
const bgColor = hasHard
? 'bg-purple-400 dark:bg-purple-600'
: 'bg-cyan-400 dark:bg-cyan-500';
const borderStyle = hasHard ? '' : 'border-t-2 border-dashed border-cyan-600';
const title = blocks.map(b => b.title).join(', ');
return (
<div
key={resource.id}
className={`flex-1 ${bgColor} ${borderStyle} flex items-center justify-center overflow-hidden`}
style={{
minWidth: 0,
marginLeft: resourceIndex > 0 ? '1px' : 0,
}}
title={`${resource.name}: ${title}`}
>
<span className="text-[8px] text-white font-bold truncate px-0.5">
{resource.name.charAt(0)}
</span>
</div>
);
})}
</div>
)}
</>
)}
</div>
@@ -1570,6 +1668,12 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
{viewMode !== 'month' && (
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
{/* Match timeline header height - add extra row for week view */}
{viewMode !== 'day' && (
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-700/50 px-4 py-2 text-sm font-semibold text-gray-500 dark:text-gray-400 shrink-0">
{viewDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
)}
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto flex-1">
@@ -1647,8 +1751,9 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
{days.map((day, dayIndex) => (
<div
key={dayIndex}
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50"
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
style={{ width: dayWidth }}
onClick={() => { setViewDate(day); setViewMode('day'); }}
>
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
@@ -1701,10 +1806,35 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
{resourceLayouts.map(layout => {
const isResourceOverQuota = overQuotaResourceIds.has(layout.resource.id);
return (<div key={layout.resource.id} className={`relative border-b border-gray-100 dark:border-gray-800 transition-colors ${isResourceOverQuota ? 'bg-amber-50/30 dark:bg-amber-900/10' : ''}`} style={{ height: layout.height }}>{layout.appointments.map(apt => {
return (<div key={layout.resource.id} className={`relative border-b border-gray-100 dark:border-gray-800 transition-colors ${isResourceOverQuota ? 'bg-amber-50/30 dark:bg-amber-900/10' : ''}`} style={{ height: layout.height }}>
{/* Clickable day columns for week view - navigate to day view */}
{viewMode === 'week' && days.map((day, dayIndex) => (
<div
key={`day-click-${dayIndex}`}
className="absolute top-0 bottom-0 cursor-pointer hover:bg-brand-500/5 transition-colors"
style={{ left: dayIndex * dayWidth, width: dayWidth }}
onClick={() => { setViewDate(day); setViewMode('day'); }}
/>
))}
{/* Time Block Overlays */}
{blockedDates.length > 0 && (
<TimeBlockCalendarOverlay
blockedDates={blockedDates}
resourceId={layout.resource.id}
viewDate={viewDate}
zoomLevel={zoomLevel}
pixelsPerMinute={PIXELS_PER_MINUTE}
startHour={START_HOUR}
dayWidth={dayWidth}
laneHeight={layout.height}
days={days}
onDayClick={viewMode === 'week' ? (day) => { setViewDate(day); setViewMode('day'); } : undefined}
/>
)}
{layout.appointments.map(apt => {
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
const service = services.find(s => s.id === apt.serviceId);
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}>
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
</div>);

View File

@@ -0,0 +1,555 @@
/**
* Time Blocks Management Page
*
* Allows business owners/managers to manage time blocks for business closures,
* holidays, and resource-specific unavailability.
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
TimeBlockListItem,
BlockType,
RecurrenceType,
} from '../types';
import {
useTimeBlocks,
useCreateTimeBlock,
useUpdateTimeBlock,
useDeleteTimeBlock,
useToggleTimeBlock,
useHolidays,
} from '../hooks/useTimeBlocks';
import { useResources } from '../hooks/useResources';
import Portal from '../components/Portal';
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
import {
Building2,
User,
Plus,
Pencil,
Trash2,
AlertTriangle,
Clock,
Calendar,
CalendarDays,
Ban,
AlertCircle,
Power,
PowerOff,
} from 'lucide-react';
type TimeBlockTab = 'business' | 'resource' | 'calendar';
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
NONE: 'One-time',
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
YEARLY: 'Yearly',
HOLIDAY: 'Holiday',
};
const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
HARD: 'Hard Block',
SOFT: 'Soft Block',
};
const TimeBlocks: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TimeBlockTab>('business');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Fetch data (include inactive blocks so users can re-enable them)
const {
data: businessBlocks = [],
isLoading: businessLoading,
} = useTimeBlocks({ level: 'business' });
const {
data: resourceBlocks = [],
isLoading: resourceLoading,
} = useTimeBlocks({ level: 'resource' });
const { data: holidays = [] } = useHolidays('US');
const { data: resources = [] } = useResources();
// Mutations
const createBlock = useCreateTimeBlock();
const updateBlock = useUpdateTimeBlock();
const deleteBlock = useDeleteTimeBlock();
const toggleBlock = useToggleTimeBlock();
// Current blocks based on tab
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
const isLoading = activeTab === 'business' ? businessLoading : resourceLoading;
// Group resource blocks by resource
const resourceBlocksGrouped = useMemo(() => {
const groups: Record<string, TimeBlockListItem[]> = {};
resourceBlocks.forEach((block) => {
const resourceId = block.resource || 'unassigned';
if (!groups[resourceId]) groups[resourceId] = [];
groups[resourceId].push(block);
});
return groups;
}, [resourceBlocks]);
// Modal handlers
const openCreateModal = () => {
setEditingBlock(null);
setIsModalOpen(true);
};
const openEditModal = (block: TimeBlockListItem) => {
setEditingBlock(block);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingBlock(null);
};
const handleDelete = async (id: string) => {
try {
await deleteBlock.mutateAsync(id);
setDeleteConfirmId(null);
} catch (error) {
console.error('Failed to delete time block:', error);
}
};
const handleToggle = async (id: string) => {
try {
await toggleBlock.mutateAsync(id);
} catch (error) {
console.error('Failed to toggle time block:', error);
}
};
// Render block type badge
const renderBlockTypeBadge = (type: BlockType) => (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
type === 'HARD'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}
>
{type === 'HARD' ? <Ban size={12} className="mr-1" /> : <AlertCircle size={12} className="mr-1" />}
{BLOCK_TYPE_LABELS[type]}
</span>
);
// Render recurrence type badge
const renderRecurrenceBadge = (type: RecurrenceType) => (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{type === 'HOLIDAY' ? (
<CalendarDays size={12} className="mr-1" />
) : type === 'NONE' ? (
<Clock size={12} className="mr-1" />
) : (
<Calendar size={12} className="mr-1" />
)}
{RECURRENCE_TYPE_LABELS[type]}
</span>
);
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('timeBlocks.title', 'Time Blocks')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('timeBlocks.subtitle', 'Manage business closures, holidays, and resource unavailability')}
</p>
</div>
<button
onClick={() => openCreateModal()}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
<Plus size={18} />
{t('timeBlocks.addBlock', 'Add Block')}
</button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-1" aria-label="Time block tabs">
<button
onClick={() => setActiveTab('business')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'business'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<Building2 size={18} />
{t('timeBlocks.businessTab', 'Business Blocks')}
{businessBlocks.length > 0 && (
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
{businessBlocks.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('resource')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'resource'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<User size={18} />
{t('timeBlocks.resourceTab', 'Resource Blocks')}
{resourceBlocks.length > 0 && (
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
{resourceBlocks.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('calendar')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'calendar'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<CalendarDays size={18} />
{t('timeBlocks.calendarTab', 'Yearly View')}
</button>
</nav>
</div>
{/* Tab Content */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
</div>
) : (
<div className="space-y-4">
{activeTab === 'business' && (
<>
{/* Business Blocks Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-300">
<Building2 size={16} className="inline mr-2" />
{t('timeBlocks.businessInfo', 'Business blocks apply to all resources. Use these for holidays, company closures, and business-wide events.')}
</p>
</div>
{/* Business Blocks List */}
{businessBlocks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<CalendarDays size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('timeBlocks.noBusinessBlocks', 'No Business Blocks')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('timeBlocks.noBusinessBlocksDesc', 'Add holidays and business closures to prevent bookings during those times.')}
</p>
<button
onClick={() => openCreateModal()}
className="btn-primary inline-flex items-center gap-2"
>
<Plus size={18} />
{t('timeBlocks.addFirstBlock', 'Add First Block')}
</button>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('timeBlocks.titleCol', 'Title')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('timeBlocks.typeCol', 'Type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('timeBlocks.patternCol', 'Pattern')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('timeBlocks.actionsCol', 'Actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{businessBlocks.map((block) => (
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
{block.title}
</span>
{!block.is_active && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
Inactive
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderBlockTypeBadge(block.block_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{renderRecurrenceBadge(block.recurrence_type)}
{block.pattern_display && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{block.pattern_display}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggle(block.id)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={block.is_active ? 'Deactivate' : 'Activate'}
>
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
</button>
<button
onClick={() => openEditModal(block)}
className="p-2 text-gray-400 hover:text-blue-600"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => setDeleteConfirmId(block.id)}
className="p-2 text-gray-400 hover:text-red-600"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{activeTab === 'resource' && (
<>
{/* Resource Blocks Info */}
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<p className="text-sm text-purple-800 dark:text-purple-300">
<User size={16} className="inline mr-2" />
{t('timeBlocks.resourceInfo', 'Resource blocks apply to specific staff or equipment. Use these for vacations, maintenance, or personal time.')}
</p>
</div>
{/* Resource Blocks List */}
{resourceBlocks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<User size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('timeBlocks.noResourceBlocks', 'No Resource Blocks')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('timeBlocks.noResourceBlocksDesc', 'Add time blocks for specific resources to manage their availability.')}
</p>
<button
onClick={() => openCreateModal()}
className="btn-primary inline-flex items-center gap-2"
>
<Plus size={18} />
{t('timeBlocks.addFirstBlock', 'Add First Block')}
</button>
</div>
) : (
<div className="space-y-4">
{resources.map((resource) => {
const blocks = resourceBlocksGrouped[resource.id] || [];
if (blocks.length === 0) return null;
return (
<div
key={resource.id}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
{resource.name}
</h3>
<button
onClick={() => openCreateModal()}
className="text-sm text-brand-600 hover:text-brand-700 flex items-center gap-1"
>
<Plus size={14} />
Add Block
</button>
</div>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{blocks.map((block) => (
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
{block.title}
</span>
{!block.is_active && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
Inactive
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderBlockTypeBadge(block.block_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{renderRecurrenceBadge(block.recurrence_type)}
{block.pattern_display && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{block.pattern_display}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggle(block.id)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={block.is_active ? 'Deactivate' : 'Activate'}
>
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
</button>
<button
onClick={() => openEditModal(block)}
className="p-2 text-gray-400 hover:text-blue-600"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => setDeleteConfirmId(block.id)}
className="p-2 text-gray-400 hover:text-red-600"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</div>
)}
</>
)}
{activeTab === 'calendar' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<YearlyBlockCalendar
onBlockClick={(blockId) => {
// Find the block and open edit modal
const block = [...businessBlocks, ...resourceBlocks].find(b => b.id === blockId);
if (block) {
openEditModal(block);
}
}}
/>
</div>
)}
</div>
)}
{/* Create/Edit Modal */}
<TimeBlockCreatorModal
isOpen={isModalOpen}
onClose={closeModal}
onSubmit={async (data) => {
try {
if (editingBlock) {
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
} else {
// Handle array of blocks (multiple holidays)
const blocks = Array.isArray(data) ? data : [data];
for (const block of blocks) {
await createBlock.mutateAsync(block);
}
}
closeModal();
} catch (error) {
console.error('Failed to save time block:', error);
}
}}
isSubmitting={createBlock.isPending || updateBlock.isPending}
editingBlock={editingBlock}
holidays={holidays}
resources={resources}
isResourceLevel={activeTab === 'resource'}
/>
{/* Delete Confirmation Modal */}
{deleteConfirmId && (
<Portal>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('timeBlocks.deleteConfirmTitle', 'Delete Time Block?')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('timeBlocks.deleteConfirmDesc', 'This action cannot be undone.')}
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
disabled={deleteBlock.isPending}
>
{deleteBlock.isPending ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.deleting', 'Deleting...')}
</span>
) : (
t('common.delete', 'Delete')
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default TimeBlocks;

View File

@@ -13,6 +13,7 @@ import {
Clock, Eye, Palette, Link2, Mail, Globe, CreditCard, Zap, Search, Filter,
Plus, Edit, Trash2, ArrowUpDown, GripVertical, Image, Save, ExternalLink,
MessageSquare, Tag, UserPlus, Shield, Copy, Layers, Play, Pause, Puzzle,
FileSignature, Send, Download, Link as LinkIcon,
} from 'lucide-react';
interface TocSubItem {
@@ -52,6 +53,7 @@ const HelpComprehensive: React.FC = () => {
{ id: 'customers', label: 'Customers', icon: <Users size={16} /> },
{ id: 'staff', label: 'Staff', icon: <UserCog size={16} /> },
{ id: 'plugins', label: 'Plugins', icon: <Puzzle size={16} /> },
{ id: 'contracts', label: 'Contracts', icon: <FileSignature size={16} /> },
{
id: 'settings',
label: 'Settings',
@@ -703,6 +705,146 @@ const HelpComprehensive: React.FC = () => {
</div>
</section>
{/* ============================================== */}
{/* CONTRACTS */}
{/* ============================================== */}
<section id="contracts" className="mb-16 scroll-mt-24">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<FileSignature size={20} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Contracts</h2>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
The Contracts feature enables electronic document signing for your business. Create reusable
templates, send contracts to customers, and maintain legally compliant audit trails with automatic
PDF generation.
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Templates</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Templates are reusable contract documents with placeholder variables that get filled in when sent:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Template Properties</h4>
<ul className="text-xs text-gray-500 dark:text-gray-400 mt-2 space-y-1">
<li>• <strong>Name:</strong> Internal template identifier</li>
<li>• <strong>Content:</strong> HTML document with variables</li>
<li>• <strong>Scope:</strong> Customer-level or per-appointment</li>
<li>• <strong>Expiration:</strong> Days until contract expires</li>
</ul>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Available Variables</h4>
<ul className="text-xs text-gray-500 dark:text-gray-400 mt-2 space-y-1">
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{CUSTOMER_NAME}}'}</code></li>
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{CUSTOMER_EMAIL}}'}</code></li>
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{BUSINESS_NAME}}'}</code></li>
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{DATE}}'}</code> and <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{YEAR}}'}</code></li>
</ul>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Workflow</h3>
<ol className="space-y-3 mb-6">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">1</span>
<div>
<span className="font-medium text-gray-900 dark:text-white text-sm">Create Contract</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Select a template and customer. Variables are automatically filled in.</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">2</span>
<div>
<span className="font-medium text-gray-900 dark:text-white text-sm">Send for Signing</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Customer receives an email with a secure signing link.</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">3</span>
<div>
<span className="font-medium text-gray-900 dark:text-white text-sm">Customer Signs</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Customer agrees via checkbox consent with full audit trail capture.</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">4</span>
<div>
<span className="font-medium text-gray-900 dark:text-white text-sm">PDF Generated</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Signed PDF with audit trail is generated and stored automatically.</p>
</div>
</li>
</ol>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Statuses</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div className="p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-center">
<span className="text-sm font-medium text-yellow-700 dark:text-yellow-300">Pending</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Awaiting signature</p>
</div>
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
<span className="text-sm font-medium text-green-700 dark:text-green-300">Signed</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Successfully completed</p>
</div>
<div className="p-2 bg-red-50 dark:bg-red-900/20 rounded-lg text-center">
<span className="text-sm font-medium text-red-700 dark:text-red-300">Expired</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Past expiration date</p>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Voided</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Manually cancelled</p>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Legal Compliance</h3>
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<Shield size={20} className="text-emerald-600 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">ESIGN & UETA Compliant</h4>
<p className="text-xs text-gray-600 dark:text-gray-300">
All signatures capture: timestamp, IP address, user agent, document hash, consent checkbox states,
and exact consent language. This creates a legally defensible audit trail.
</p>
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Key Features</h3>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
<li className="flex items-center gap-2">
<Send size={16} className="text-emerald-500" />
<span><strong>Email Delivery:</strong> Contracts are sent directly to customer email with signing link</span>
</li>
<li className="flex items-center gap-2">
<LinkIcon size={16} className="text-emerald-500" />
<span><strong>Shareable Links:</strong> Copy signing link to share via other channels</span>
</li>
<li className="flex items-center gap-2">
<Download size={16} className="text-emerald-500" />
<span><strong>PDF Download:</strong> Download signed contracts with full audit trail</span>
</li>
<li className="flex items-center gap-2">
<Eye size={16} className="text-emerald-500" />
<span><strong>Status Tracking:</strong> Monitor which contracts are pending, signed, or expired</span>
</li>
</ul>
</div>
<Link to="/help/contracts" onClick={scrollToTop} className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<FileSignature size={24} className="text-emerald-500" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Contracts Documentation</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Complete guide to templates, signing, and compliance features</p>
</div>
<ChevronRight size={20} className="text-gray-400 ml-auto" />
</Link>
</section>
{/* ============================================== */}
{/* SETTINGS */}
{/* ============================================== */}

View File

@@ -0,0 +1,299 @@
/**
* Help Contracts Page
*/
import React from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft, FileSignature, FileText, Send, CheckCircle, ChevronRight,
HelpCircle, Shield, Clock, Users, AlertCircle, Copy, Eye, Download,
} from 'lucide-react';
const HelpContracts: React.FC = () => {
const navigate = useNavigate();
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6">
<ArrowLeft size={20} /> Back
</button>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<FileSignature size={24} className="text-brand-600 dark:text-brand-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Contracts Guide</h1>
<p className="text-gray-500 dark:text-gray-400">Create and manage digital contracts with e-signatures</p>
</div>
</div>
</div>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileSignature size={20} className="text-brand-500" /> Overview
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
The Contracts system allows you to create reusable contract templates, send them to customers for digital signature, and maintain legally compliant records with full audit trails.
</p>
<p className="text-gray-600 dark:text-gray-300">
All signatures are captured with ESIGN Act and UETA compliance, including IP address, timestamp, browser information, and optional geolocation for maximum legal protection.
</p>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileText size={20} className="text-brand-500" /> Contract Templates
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Templates are reusable contract documents that can be personalized with variable placeholders.
</p>
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Template Variables</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Use these placeholders in your templates - they'll be automatically replaced when the contract is created:
</p>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm font-mono">
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_NAME}}"}</span> - Full name</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_FIRST_NAME}}"}</span> - First name</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_EMAIL}}"}</span> - Email address</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_PHONE}}"}</span> - Phone number</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_NAME}}"}</span> - Your business name</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_EMAIL}}"}</span> - Contact email</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_PHONE}}"}</span> - Business phone</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{DATE}}"}</span> - Current date</div>
<div><span className="text-blue-600 dark:text-blue-400">{"{{YEAR}}"}</span> - Current year</div>
</div>
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Template Scopes</h3>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<Users size={20} className="text-blue-500 mt-0.5" />
<div>
<span className="font-medium text-gray-900 dark:text-white">Customer-Level</span>
<p className="text-sm text-gray-500 dark:text-gray-400">One-time contracts per customer (e.g., privacy policy, terms of service)</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<Clock size={20} className="text-green-500 mt-0.5" />
<div>
<span className="font-medium text-gray-900 dark:text-white">Appointment-Level</span>
<p className="text-sm text-gray-500 dark:text-gray-400">Signed for each booking (e.g., liability waivers, service agreements)</p>
</div>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Send size={20} className="text-brand-500" /> Sending Contracts
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">1</span>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Select a Template</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Choose from your active contract templates</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">2</span>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Choose a Customer</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Variables are automatically filled with customer data</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">3</span>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Send for Signature</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Customer receives an email with a secure signing link</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">4</span>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Track Status</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Monitor pending, signed, expired, or voided contracts</p>
</div>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Eye size={20} className="text-brand-500" /> Contract Status
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="font-medium text-gray-900 dark:text-white">Pending</span>
<span className="text-sm text-gray-500 dark:text-gray-400">- Awaiting customer signature</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="font-medium text-gray-900 dark:text-white">Signed</span>
<span className="text-sm text-gray-500 dark:text-gray-400">- Customer has signed the contract</span>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-gray-500" />
<span className="font-medium text-gray-900 dark:text-white">Expired</span>
<span className="text-sm text-gray-500 dark:text-gray-400">- Contract expired before signing</span>
</div>
<div className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="font-medium text-gray-900 dark:text-white">Voided</span>
<span className="text-sm text-gray-500 dark:text-gray-400">- Contract was cancelled by business</span>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Legal Compliance
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<AlertCircle size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>ESIGN Act & UETA Compliant:</strong> All signatures include comprehensive audit trails that meet federal and state requirements for electronic signatures.
</p>
</div>
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Captured Audit Data</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Document hash (SHA-256)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Signature timestamp (ISO)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Signer's IP address</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Browser/device information</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Consent checkbox states</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<CheckCircle size={16} className="text-green-500" />
<span>Geolocation (if permitted)</span>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Download size={20} className="text-brand-500" /> PDF Generation
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Once a contract is signed, a PDF is automatically generated that includes:
</p>
<ul className="space-y-2">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">The full contract content with substituted variables</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">Signature section with signer's name and consent confirmations</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">Complete audit trail footer with all verification data</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">Your business branding and logo</span>
</li>
</ul>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CheckCircle size={20} className="text-brand-500" /> Best Practices
</h2>
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
<ul className="space-y-3">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300"><strong>Use Clear Language:</strong> Write contracts in plain language that customers can easily understand</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300"><strong>Set Expiration Dates:</strong> Use the expiration feature to ensure contracts are signed in a timely manner</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300"><strong>Link to Services:</strong> Associate contracts with specific services for automatic requirement checking</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300"><strong>Version Control:</strong> Create new versions rather than editing existing active templates</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300"><strong>Download PDFs:</strong> Keep copies of signed contracts for your records</span>
</li>
</ul>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Related Features</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link to="/help/services" className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-brand-500 transition-colors">
<FileText size={20} className="text-brand-500" />
<span className="text-gray-900 dark:text-white">Services Guide</span>
<ChevronRight size={16} className="text-gray-400 ml-auto" />
</Link>
<Link to="/help/customers" className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-brand-500 transition-colors">
<Users size={20} className="text-brand-500" />
<span className="text-gray-900 dark:text-white">Customers Guide</span>
<ChevronRight size={16} className="text-gray-400 ml-auto" />
</Link>
</div>
</section>
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Need More Help?</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">Our support team is ready to help with any questions about contracts.</p>
<button onClick={() => navigate('/tickets')} className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">Contact Support</button>
</section>
</div>
);
};
export default HelpContracts;

View File

@@ -448,4 +448,197 @@ export interface EmailTemplateVariable {
export interface EmailTemplateVariableGroup {
category: string;
items: EmailTemplateVariable[];
}
// --- Contract Types ---
export type ContractScope = 'CUSTOMER' | 'APPOINTMENT';
export type ContractStatus = 'PENDING' | 'SIGNED' | 'EXPIRED' | 'VOIDED';
export type ContractTemplateStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
export interface ContractTemplate {
id: string;
name: string;
description: string;
content: string;
scope: ContractScope;
status: ContractTemplateStatus;
expires_after_days: number | null;
version: number;
version_notes: string;
services: { id: string; name: string }[];
created_by: string | null;
created_by_name: string | null;
created_at: string;
updated_at: string;
}
export interface Contract {
id: string;
template: string;
template_name: string;
template_version: number;
scope: ContractScope;
status: ContractStatus;
content: string;
customer?: string;
customer_name?: string;
customer_email?: string;
appointment?: string;
appointment_service_name?: string;
appointment_start_time?: string;
service?: string;
service_name?: string;
sent_at: string | null;
signed_at: string | null;
expires_at: string | null;
voided_at: string | null;
voided_reason: string | null;
public_token: string;
created_at: string;
updated_at: string;
}
export interface ContractSignature {
id: string;
contract: string;
signer_name: string;
signer_email: string;
signature_data: string;
ip_address: string;
user_agent: string;
signed_at: string;
}
export interface ContractPublicView {
contract: Contract;
template: {
name: string;
content: string;
};
business: {
name: string;
logo_url?: string;
};
customer?: {
name: string;
email: string;
};
appointment?: {
service_name: string;
start_time: string;
};
is_expired: boolean;
can_sign: boolean;
signature?: ContractSignature;
}
// --- Time Blocking Types ---
export type BlockType = 'HARD' | 'SOFT';
export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
export type TimeBlockLevel = 'business' | 'resource';
export type HolidayType = 'FIXED' | 'FLOATING' | 'CALCULATED';
export interface Holiday {
code: string;
name: string;
country: string;
holiday_type?: HolidayType;
month?: number;
day?: number;
week_of_month?: number;
day_of_week?: number;
calculation_rule?: string;
is_active?: boolean;
next_occurrence?: string; // ISO date string
}
export interface RecurrencePattern {
days_of_week?: number[]; // 0=Mon, 6=Sun (for WEEKLY)
days_of_month?: number[]; // 1-31 (for MONTHLY)
month?: number; // 1-12 (for YEARLY)
day?: number; // 1-31 (for YEARLY)
holiday_code?: string; // holiday code (for HOLIDAY)
}
export interface TimeBlock {
id: string;
title: string;
description?: string;
resource?: string | null; // Resource ID or null for business-level
resource_name?: string;
level: TimeBlockLevel;
block_type: BlockType;
recurrence_type: RecurrenceType;
start_date?: string; // ISO date string (for NONE type)
end_date?: string; // ISO date string (for NONE type)
all_day: boolean;
start_time?: string; // HH:MM:SS (if not all_day)
end_time?: string; // HH:MM:SS (if not all_day)
recurrence_pattern?: RecurrencePattern;
pattern_display?: string; // Human-readable pattern description
holiday_name?: string; // Holiday name if HOLIDAY type
recurrence_start?: string; // ISO date string
recurrence_end?: string; // ISO date string
is_active: boolean;
created_by?: string;
created_by_name?: string;
conflict_count?: number;
created_at: string;
updated_at?: string;
}
export interface TimeBlockListItem {
id: string;
title: string;
description?: string;
resource?: string | null;
resource_name?: string;
level: TimeBlockLevel;
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;
pattern_display?: string;
is_active: boolean;
created_at: string;
}
export interface BlockedDate {
date: string; // ISO date string
block_type: BlockType;
title: string;
resource_id: string | null;
all_day: boolean;
start_time: string | null;
end_time: string | null;
time_block_id: string;
}
export interface TimeBlockConflict {
event_id: string;
title: string;
start_time: string;
end_time: string;
}
export interface TimeBlockConflictCheck {
has_conflicts: boolean;
conflict_count: number;
conflicts: TimeBlockConflict[];
}
export interface MyBlocksResponse {
business_blocks: TimeBlockListItem[];
my_blocks: TimeBlockListItem[];
resource_id: string;
resource_name: string;
}

View File

@@ -18,7 +18,14 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
# psycopg dependencies
libpq-dev \
gettext \
wait-for-it
wait-for-it \
# WeasyPrint dependencies for PDF generation
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz-subset0 \
libffi-dev \
libcairo2 \
libgdk-pixbuf-2.0-0
# Requirements are installed here to ensure they will be cached.
RUN --mount=type=cache,target=/root/.cache/uv \

View File

@@ -49,6 +49,13 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
wait-for-it \
# SSH client for mail server management
openssh-client \
# WeasyPrint dependencies for PDF generation
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz-subset0 \
libffi-dev \
libcairo2 \
libgdk-pixbuf-2.0-0 \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -56,6 +56,7 @@ TENANT_APPS = [
'django.contrib.contenttypes', # Needed for tenant schemas
'schedule', # Resource scheduling with configurable concurrency
'payments', # Stripe Connect payments bridge
'contracts', # Contract/e-signature system
# Add your tenant-scoped business logic apps here:
# 'appointments',
# 'customers',

View File

@@ -78,6 +78,8 @@ urlpatterns += [
path("", include("analytics.urls")),
# Payments API
path("payments/", include("payments.urls")),
# Contracts API
path("contracts/", include("contracts.urls")),
# Communication Credits API
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
# Tickets API

View File

@@ -0,0 +1,118 @@
# Contract PDF Generation Setup
The contracts app includes PDF generation capabilities using WeasyPrint. However, WeasyPrint requires system-level dependencies that need to be installed in the Docker container.
## Current Status
**PDF Service Code**: Installed and ready
**Celery Tasks**: Installed and functional
**HTML Templates**: Installed and ready
**WeasyPrint Dependencies**: **NOT INSTALLED**
## To Enable PDF Generation
### 1. Update Docker Image
Edit `compose/local/django/Dockerfile` (or production equivalent) and add the following before pip installs:
```dockerfile
# Install WeasyPrint dependencies
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libgobject-2.0-0 \
libcairo2 \
libpangocairo-1.0-0 \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
```
### 2. Add to Requirements
Add to your requirements file (e.g., `requirements/base.txt` or `requirements/local.txt`):
```
weasyprint>=67.0
```
### 3. Rebuild Container
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml up -d --build django
```
### 4. Verify Installation
```bash
docker compose -f docker-compose.local.yml exec django python -c "from contracts.pdf_service import ContractPDFService; print('Available:', ContractPDFService.is_available())"
```
You should see: `Available: True`
## What Works Without WeasyPrint
Even without WeasyPrint installed, the following functionality works:
- ✅ Contract creation and management
- ✅ Contract signing workflow
- ✅ Email notifications (signing requests, reminders, confirmations)
- ✅ Signature audit trail
- ✅ All Celery tasks (except PDF generation)
The only limitation is that `generate_contract_pdf` task will fail with a clear error message indicating WeasyPrint is not available.
## Files Created
- `/app/contracts/pdf_service.py` - PDF generation service
- `/app/contracts/tasks.py` - Celery tasks for contracts
- `/app/templates/contracts/pdf_template.html` - PDF template
- `/app/templates/contracts/emails/` - Email templates (8 files)
## Testing PDF Generation
Once WeasyPrint is installed, you can test PDF generation:
```python
from contracts.models import Contract
from contracts.pdf_service import ContractPDFService
# Get a signed contract
contract = Contract.objects.filter(status='SIGNED').first()
# Generate PDF
pdf_bytes = ContractPDFService.generate_pdf(contract)
# Or save to storage
pdf_path = ContractPDFService.save_contract_pdf(contract)
print(f"Saved to: {pdf_path}")
```
## Celery Tasks
All tasks are registered and will be autodiscovered by Celery:
1. `send_contract_email(contract_id)` - Send signing request
2. `send_contract_reminder(contract_id)` - Send reminder
3. `send_contract_signed_emails(contract_id)` - Send confirmation emails
4. `generate_contract_pdf(contract_id)` - Generate PDF (requires WeasyPrint)
5. `check_expired_contracts()` - Periodic task to mark expired contracts
6. `send_pending_reminders()` - Periodic task to send reminders 3 days before expiry
## Periodic Tasks
Add to your Celery beat schedule:
```python
# In config/settings/base.py or local.py
CELERY_BEAT_SCHEDULE = {
'check-expired-contracts': {
'task': 'contracts.tasks.check_expired_contracts',
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
},
'send-contract-reminders': {
'task': 'contracts.tasks.send_pending_reminders',
'schedule': crontab(hour=10, minute=0), # Daily at 10 AM
},
}
```

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContractsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'contracts'

View File

@@ -0,0 +1,115 @@
# Generated by Django 5.2.8 on 2025-12-04 19:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('schedule', '0027_add_deposit_percent_back'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ContractTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('content', models.TextField(help_text='Contract body with {{VARIABLE}} placeholders')),
('scope', models.CharField(choices=[('CUSTOMER', 'Customer-Level'), ('APPOINTMENT', 'Appointment-Level')], default='APPOINTMENT', max_length=20)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
('expires_after_days', models.PositiveIntegerField(blank=True, help_text='Days after sending before contract expires (null=never)', null=True)),
('version', models.PositiveIntegerField(default=1)),
('version_notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_contract_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Contract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('template_version', models.PositiveIntegerField(help_text='Version of template when contract was created')),
('title', models.CharField(max_length=200)),
('content_html', models.TextField(help_text='Rendered HTML content at time of creation')),
('content_hash', models.CharField(help_text='SHA-256 hash of content for tamper detection', max_length=64)),
('status', models.CharField(choices=[('PENDING', 'Pending Signature'), ('SIGNED', 'Signed'), ('EXPIRED', 'Expired'), ('VOIDED', 'Voided')], default='PENDING', max_length=20)),
('signing_token', models.CharField(help_text='Token for public signing URL', max_length=64, unique=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('pdf_path', models.CharField(blank=True, max_length=500)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to=settings.AUTH_USER_MODEL)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='schedule.event')),
('sent_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_contracts', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='contracts.contracttemplate')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ServiceContractRequirement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_order', models.PositiveIntegerField(default=0)),
('is_required', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contract_requirements', to='schedule.service')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_requirements', to='contracts.contracttemplate')),
],
options={
'ordering': ['display_order'],
},
),
migrations.CreateModel(
name='ContractSignature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('consent_checkbox_checked', models.BooleanField(default=False)),
('consent_text', models.TextField(help_text='Exact consent language shown to signer')),
('electronic_consent_given', models.BooleanField(default=False)),
('electronic_consent_text', models.TextField(help_text='ESIGN Act consent text')),
('signer_name', models.CharField(max_length=200)),
('signer_email', models.EmailField(max_length=254)),
('signed_at', models.DateTimeField()),
('ip_address', models.GenericIPAddressField()),
('user_agent', models.TextField()),
('browser_fingerprint', models.CharField(blank=True, max_length=64)),
('document_hash_at_signing', models.CharField(help_text='SHA-256 hash of contract content at moment of signing', max_length=64)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('contract', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='signature', to='contracts.contract')),
],
options={
'indexes': [models.Index(fields=['signed_at'], name='contracts_c_signed__e562eb_idx')],
},
),
migrations.AddIndex(
model_name='contract',
index=models.Index(fields=['signing_token'], name='contracts_c_signing_4e91ca_idx'),
),
migrations.AddIndex(
model_name='contract',
index=models.Index(fields=['customer', 'status'], name='contracts_c_custome_791003_idx'),
),
migrations.AddIndex(
model_name='contract',
index=models.Index(fields=['template', 'customer'], name='contracts_c_templat_e832ba_idx'),
),
migrations.AlterUniqueTogether(
name='servicecontractrequirement',
unique_together={('service', 'template')},
),
]

View File

@@ -0,0 +1,265 @@
"""
Contract/E-Signature models for SmoothSchedule.
Provides ESIGN Act/UETA compliant digital signatures.
"""
import hashlib
import secrets
from django.db import models
from django.utils import timezone
class ContractTemplate(models.Model):
"""
Reusable contract template with variable substitution.
Business-specific templates stored in tenant schema.
"""
class Scope(models.TextChoices):
CUSTOMER = "CUSTOMER", "Customer-Level" # One-time per customer
APPOINTMENT = "APPOINTMENT", "Appointment-Level" # Per booking
class Status(models.TextChoices):
DRAFT = "DRAFT", "Draft"
ACTIVE = "ACTIVE", "Active"
ARCHIVED = "ARCHIVED", "Archived"
# Basic info
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Contract content (HTML with {{variables}})
content = models.TextField(help_text="Contract body with {{VARIABLE}} placeholders")
# Scope and status
scope = models.CharField(
max_length=20,
choices=Scope.choices,
default=Scope.APPOINTMENT
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT
)
# Expiration settings
expires_after_days = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Days after sending before contract expires (null=never)"
)
# Version tracking for legal compliance
version = models.PositiveIntegerField(default=1)
version_notes = models.TextField(blank=True)
# Metadata
created_by = models.ForeignKey(
"users.User",
on_delete=models.SET_NULL,
null=True,
related_name="created_contract_templates"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return f"{self.name} (v{self.version})"
class ServiceContractRequirement(models.Model):
"""
Links services to required contract templates.
When booking a service, these contracts must be signed.
"""
service = models.ForeignKey(
"schedule.Service",
on_delete=models.CASCADE,
related_name="contract_requirements"
)
template = models.ForeignKey(
ContractTemplate,
on_delete=models.CASCADE,
related_name="service_requirements"
)
# Ordering for multiple contracts
display_order = models.PositiveIntegerField(default=0)
# Whether this is required or optional
is_required = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["display_order"]
unique_together = ["service", "template"]
def __str__(self):
return f"{self.service.name} requires {self.template.name}"
class Contract(models.Model):
"""
Instance of a contract sent to a customer.
Contains the rendered content at time of sending (snapshot).
"""
class Status(models.TextChoices):
PENDING = "PENDING", "Pending Signature"
SIGNED = "SIGNED", "Signed"
EXPIRED = "EXPIRED", "Expired"
VOIDED = "VOIDED", "Voided"
# Source template (for reference, content is snapshotted)
template = models.ForeignKey(
ContractTemplate,
on_delete=models.SET_NULL,
null=True,
related_name="contracts"
)
template_version = models.PositiveIntegerField(
help_text="Version of template when contract was created"
)
# Content snapshot (frozen at creation)
title = models.CharField(max_length=200)
content_html = models.TextField(help_text="Rendered HTML content at time of creation")
content_hash = models.CharField(
max_length=64,
help_text="SHA-256 hash of content for tamper detection"
)
# Customer
customer = models.ForeignKey(
"users.User",
on_delete=models.CASCADE,
related_name="contracts"
)
# Optional appointment link (null for customer-level contracts)
event = models.ForeignKey(
"schedule.Event",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="contracts"
)
# Status and token
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
signing_token = models.CharField(
max_length=64,
unique=True,
help_text="Token for public signing URL"
)
# Expiration
expires_at = models.DateTimeField(null=True, blank=True)
# Metadata
sent_by = models.ForeignKey(
"users.User",
on_delete=models.SET_NULL,
null=True,
related_name="sent_contracts"
)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# PDF storage path (generated after signing)
pdf_path = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["signing_token"]),
models.Index(fields=["customer", "status"]),
models.Index(fields=["template", "customer"]),
]
def save(self, *args, **kwargs):
if not self.signing_token:
self.signing_token = secrets.token_urlsafe(48)
if not self.content_hash and self.content_html:
self.content_hash = hashlib.sha256(self.content_html.encode()).hexdigest()
super().save(*args, **kwargs)
def get_signing_url(self, request=None):
"""Generate the public signing URL."""
from django.conf import settings
base_url = getattr(settings, "FRONTEND_URL", "")
if not base_url and request:
base_url = f"{request.scheme}://{request.get_host()}"
return f"{base_url}/sign/{self.signing_token}"
def __str__(self):
return f"{self.title} - {self.customer.email} ({self.status})"
class ContractSignature(models.Model):
"""
Audit trail for contract signature.
Contains all legally required data for ESIGN Act/UETA compliance.
"""
contract = models.OneToOneField(
Contract,
on_delete=models.CASCADE,
related_name="signature"
)
# Signature method
consent_checkbox_checked = models.BooleanField(default=False)
consent_text = models.TextField(
help_text="Exact consent language shown to signer"
)
# Electronic consent to conduct business electronically
electronic_consent_given = models.BooleanField(default=False)
electronic_consent_text = models.TextField(
help_text="ESIGN Act consent text"
)
# Signer identification
signer_name = models.CharField(max_length=200)
signer_email = models.EmailField()
# Audit trail (ESIGN/UETA compliance)
signed_at = models.DateTimeField()
ip_address = models.GenericIPAddressField()
user_agent = models.TextField()
browser_fingerprint = models.CharField(max_length=64, blank=True)
# Document integrity
document_hash_at_signing = models.CharField(
max_length=64,
help_text="SHA-256 hash of contract content at moment of signing"
)
# Geolocation (optional, if available)
latitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
class Meta:
indexes = [
models.Index(fields=["signed_at"]),
]
def __str__(self):
return f"Signature by {self.signer_name} on {self.signed_at}"

View File

@@ -0,0 +1,299 @@
"""
PDF generation service for contract documents using WeasyPrint.
Generates legally compliant contract PDFs with audit trails.
NOTE: WeasyPrint requires system dependencies (Pango, GObject, Cairo).
To enable PDF generation, add these to the Docker image:
apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2
pip install weasyprint
"""
import logging
from io import BytesIO
from django.template.loader import render_to_string
from django.conf import settings
logger = logging.getLogger(__name__)
try:
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
WEASYPRINT_AVAILABLE = True
except (ImportError, OSError) as e:
logger.warning(f"WeasyPrint not available: {e}")
WEASYPRINT_AVAILABLE = False
HTML = None
CSS = None
FontConfiguration = None
class ContractPDFService:
"""
Service for generating contract PDFs with audit trail and legal compliance.
"""
@staticmethod
def generate_pdf(contract):
"""
Generate a PDF from a signed contract.
Args:
contract: Contract instance (must have signature)
Returns:
BytesIO: PDF file as bytes
Raises:
ValueError: If contract is not signed or signature data is missing
RuntimeError: If WeasyPrint is not available
"""
if not WEASYPRINT_AVAILABLE:
raise RuntimeError(
"WeasyPrint is not available. Please install system dependencies: "
"libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2, "
"then pip install weasyprint"
)
if contract.status != 'SIGNED':
raise ValueError("Contract must be signed before generating PDF")
if not hasattr(contract, 'signature') or not contract.signature:
raise ValueError("Contract signature data is missing")
signature = contract.signature
# Get tenant/business info
from django.db import connection
from core.models import Tenant
tenant = None
if hasattr(connection, 'tenant'):
tenant = connection.tenant
else:
# Fallback - try to get tenant from schema
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
# Prepare context for template
context = {
'contract': contract,
'signature': signature,
'tenant': tenant,
'business_name': tenant.name if tenant else 'SmoothSchedule',
'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
'customer': contract.customer,
'event': contract.event,
# Geolocation display
'geolocation': None,
# Legal compliance text
'esign_notice': (
"This document is a legally binding contract executed under the "
"U.S. Electronic Signatures in Global and National Commerce Act (ESIGN Act) "
"and the Uniform Electronic Transactions Act (UETA). "
"By electronically signing this document, all parties agree that such signature "
"is the legal equivalent of their manual signature."
),
}
# Format geolocation if available
if signature.latitude and signature.longitude:
context['geolocation'] = f"{signature.latitude}, {signature.longitude}"
# Render HTML from template
html_string = render_to_string('contracts/pdf_template.html', context)
# Configure fonts
font_config = FontConfiguration()
# Generate PDF
html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
# Custom CSS for better PDF rendering
css_string = """
@page {
size: letter;
margin: 1in 0.75in;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
}
table {
page-break-inside: avoid;
}
"""
css = CSS(string=css_string, font_config=font_config)
# Render to PDF
pdf_bytes = BytesIO()
html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
pdf_bytes.seek(0)
logger.info(f"Generated PDF for contract {contract.id} ({contract.title})")
return pdf_bytes
@staticmethod
def save_contract_pdf(contract, storage_path=None):
"""
Generate and save contract PDF to storage.
Args:
contract: Contract instance
storage_path: Optional custom storage path
Returns:
str: Path where PDF was saved
Raises:
ValueError: If contract validation fails
RuntimeError: If WeasyPrint is not available
"""
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
import os
# Generate PDF
pdf_bytes = ContractPDFService.generate_pdf(contract)
# Determine storage path
if not storage_path:
# Use contract signing token as filename (unique)
filename = f"contract_{contract.signing_token}.pdf"
storage_path = os.path.join('contracts', str(contract.customer.id), filename)
# Save to storage
saved_path = default_storage.save(storage_path, ContentFile(pdf_bytes.read()))
# Update contract model
contract.pdf_path = saved_path
contract.save(update_fields=['pdf_path'])
logger.info(f"Saved contract PDF to: {saved_path}")
return saved_path
@staticmethod
def is_available():
"""Check if PDF generation is available."""
return WEASYPRINT_AVAILABLE
@staticmethod
def generate_template_preview(template, user=None):
"""
Generate a PDF preview from a contract template with sample data.
Args:
template: ContractTemplate instance
user: Optional user requesting the preview
Returns:
BytesIO: PDF file as bytes
Raises:
RuntimeError: If WeasyPrint is not available
"""
if not WEASYPRINT_AVAILABLE:
raise RuntimeError(
"WeasyPrint is not available. Please install system dependencies."
)
from django.db import connection
from django.utils import timezone
from core.models import Tenant
# Get tenant info
tenant = None
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
# Sample data for variable substitution
sample_context = {
"CUSTOMER_NAME": "John Smith",
"CUSTOMER_FIRST_NAME": "John",
"CUSTOMER_LAST_NAME": "Smith",
"CUSTOMER_EMAIL": "john.smith@example.com",
"CUSTOMER_PHONE": "(555) 123-4567",
"BUSINESS_NAME": tenant.name if tenant else "Your Business",
"BUSINESS_EMAIL": tenant.contact_email if tenant else "contact@example.com",
"BUSINESS_PHONE": tenant.phone if tenant else "(555) 000-0000",
"DATE": timezone.now().strftime("%B %d, %Y"),
"YEAR": timezone.now().strftime("%Y"),
"APPOINTMENT_DATE": timezone.now().strftime("%B %d, %Y"),
"APPOINTMENT_TIME": "10:00 AM",
"SERVICE_NAME": "Sample Service",
}
# Substitute variables in content
content = template.content
for key, value in sample_context.items():
content = content.replace(f"{{{{{key}}}}}", str(value or ""))
# Prepare context for template
context = {
'template': template,
'content_html': content,
'tenant': tenant,
'business_name': tenant.name if tenant else 'SmoothSchedule',
'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
'is_preview': True,
'preview_notice': 'PREVIEW - This is a sample preview with placeholder data',
}
# Render HTML from template
html_string = render_to_string('contracts/pdf_preview_template.html', context)
# Configure fonts
font_config = FontConfiguration()
# Generate PDF
html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
# Custom CSS for PDF rendering
css_string = """
@page {
size: letter;
margin: 1in 0.75in;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
}
.preview-banner {
background: #fef3c7;
border: 2px solid #f59e0b;
color: #92400e;
padding: 12px;
text-align: center;
font-weight: bold;
margin-bottom: 24px;
border-radius: 4px;
}
"""
css = CSS(string=css_string, font_config=font_config)
# Render to PDF
pdf_bytes = BytesIO()
html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
pdf_bytes.seek(0)
logger.info(f"Generated preview PDF for template {template.id} ({template.name})")
return pdf_bytes

View File

@@ -0,0 +1,195 @@
"""
Serializers for Contract/E-Signature system.
"""
from rest_framework import serializers
from .models import ContractTemplate, ServiceContractRequirement, Contract, ContractSignature
class ContractTemplateSerializer(serializers.ModelSerializer):
"""Full serializer for template CRUD"""
services = serializers.SerializerMethodField()
created_by_name = serializers.SerializerMethodField()
class Meta:
model = ContractTemplate
fields = [
"id", "name", "description", "content", "scope", "status",
"expires_after_days", "version", "version_notes", "services",
"created_by", "created_by_name", "created_at", "updated_at"
]
read_only_fields = ["version", "created_by", "created_at", "updated_at"]
def get_services(self, obj):
requirements = obj.service_requirements.select_related("service")
return [{"id": r.service.id, "name": r.service.name} for r in requirements]
def get_created_by_name(self, obj):
if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email
return None
class ContractTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for dropdowns/lists"""
class Meta:
model = ContractTemplate
fields = ["id", "name", "scope", "status", "version"]
class ServiceContractRequirementSerializer(serializers.ModelSerializer):
template_name = serializers.CharField(source="template.name", read_only=True)
template_scope = serializers.CharField(source="template.scope", read_only=True)
service_name = serializers.CharField(source="service.name", read_only=True)
class Meta:
model = ServiceContractRequirement
fields = [
"id", "service", "service_name", "template", "template_name",
"template_scope", "display_order", "is_required", "created_at"
]
class ContractSignatureSerializer(serializers.ModelSerializer):
class Meta:
model = ContractSignature
fields = [
"signed_at", "signer_name", "signer_email", "ip_address",
"consent_checkbox_checked", "electronic_consent_given"
]
class ContractSerializer(serializers.ModelSerializer):
customer_name = serializers.SerializerMethodField()
customer_email = serializers.CharField(source="customer.email", read_only=True)
template_name = serializers.SerializerMethodField()
is_signed = serializers.SerializerMethodField()
signature_details = ContractSignatureSerializer(source="signature", read_only=True)
signing_url = serializers.SerializerMethodField()
class Meta:
model = Contract
fields = [
"id", "template", "template_name", "template_version", "title",
"content_html", "customer", "customer_name", "customer_email",
"event", "status", "expires_at", "is_signed", "signature_details",
"signing_url", "pdf_path", "sent_at", "created_at", "updated_at"
]
read_only_fields = [
"template_version", "content_html", "content_hash",
"signing_token", "sent_at", "created_at", "updated_at"
]
def get_customer_name(self, obj):
return obj.customer.get_full_name() or obj.customer.email
def get_template_name(self, obj):
return obj.template.name if obj.template else obj.title
def get_is_signed(self, obj):
return obj.status == Contract.Status.SIGNED
def get_signing_url(self, obj):
request = self.context.get("request")
return obj.get_signing_url(request)
class ContractListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for contract lists"""
customer_name = serializers.SerializerMethodField()
customer_email = serializers.CharField(source="customer.email", read_only=True)
template_name = serializers.SerializerMethodField()
is_signed = serializers.SerializerMethodField()
class Meta:
model = Contract
fields = [
"id", "title", "customer", "customer_name", "customer_email",
"status", "is_signed", "template_name", "expires_at",
"sent_at", "created_at"
]
def get_customer_name(self, obj):
return obj.customer.get_full_name() or obj.customer.email
def get_template_name(self, obj):
return obj.template.name if obj.template else obj.title
def get_is_signed(self, obj):
return obj.status == Contract.Status.SIGNED
class PublicContractSerializer(serializers.ModelSerializer):
"""Serializer for public signing endpoint (no auth required)"""
business_name = serializers.SerializerMethodField()
business_logo = serializers.SerializerMethodField()
class Meta:
model = Contract
fields = [
"id", "title", "content_html", "status", "expires_at",
"business_name", "business_logo"
]
def get_business_name(self, obj):
from django.db import connection
from core.models import Tenant
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
return tenant.name
except Tenant.DoesNotExist:
return "Business"
def get_business_logo(self, obj):
from django.db import connection
from core.models import Tenant
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
return tenant.logo.url if tenant.logo else None
except (Tenant.DoesNotExist, ValueError):
return None
class ContractSignatureInputSerializer(serializers.Serializer):
"""Input for signing a contract"""
consent_checkbox_checked = serializers.BooleanField()
electronic_consent_given = serializers.BooleanField()
signer_name = serializers.CharField(max_length=200)
latitude = serializers.DecimalField(
max_digits=9, decimal_places=6, required=False, allow_null=True
)
longitude = serializers.DecimalField(
max_digits=9, decimal_places=6, required=False, allow_null=True
)
class CreateContractSerializer(serializers.Serializer):
"""Input for creating a contract from template"""
template = serializers.PrimaryKeyRelatedField(
queryset=ContractTemplate.objects.filter(status=ContractTemplate.Status.ACTIVE)
)
customer_id = serializers.IntegerField()
event_id = serializers.IntegerField(required=False, allow_null=True)
send_email = serializers.BooleanField(default=True)
def validate_customer_id(self, value):
from smoothschedule.users.models import User
try:
customer = User.objects.get(id=value, role=User.Role.CUSTOMER)
return customer
except User.DoesNotExist:
raise serializers.ValidationError("Customer not found")
def validate_event_id(self, value):
if value is None:
return None
from schedule.models import Event
try:
return Event.objects.get(id=value)
except Event.DoesNotExist:
raise serializers.ValidationError("Event not found")
def validate(self, attrs):
# Transform the validated objects into proper fields
attrs['customer'] = attrs.pop('customer_id')
attrs['event'] = attrs.pop('event_id', None)
return attrs

View File

@@ -0,0 +1,376 @@
"""
Celery tasks for contract management.
Handles email notifications, reminders, PDF generation, and expiration.
"""
import logging
from celery import shared_task
from django.utils import timezone
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from datetime import timedelta
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def send_contract_email(self, contract_id):
"""
Send initial contract signing request email to customer.
Args:
contract_id: ID of the Contract to send
Returns:
dict: Result with success status and details
"""
from .models import Contract
from django.db import connection
from core.models import Tenant
try:
contract = Contract.objects.select_related('customer', 'template').get(id=contract_id)
except Contract.DoesNotExist:
logger.error(f"Contract {contract_id} not found")
return {'success': False, 'error': 'Contract not found'}
# Get tenant info
tenant = None
if hasattr(connection, 'tenant'):
tenant = connection.tenant
else:
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
logger.warning(f"Could not find tenant for contract {contract_id}")
business_name = tenant.name if tenant else 'SmoothSchedule'
from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
# Get signing URL
signing_url = contract.get_signing_url()
# Render email content
context = {
'contract': contract,
'customer': contract.customer,
'business_name': business_name,
'signing_url': signing_url,
'expires_at': contract.expires_at,
}
subject = f"Please Sign: {contract.title}"
html_message = render_to_string('contracts/emails/signing_request.html', context)
plain_message = render_to_string('contracts/emails/signing_request.txt', context)
try:
send_mail(
subject=subject,
message=plain_message,
from_email=from_email,
recipient_list=[contract.customer.email],
html_message=html_message,
fail_silently=False,
)
# Update contract
contract.sent_at = timezone.now()
contract.save(update_fields=['sent_at'])
logger.info(f"Sent contract signing email for contract {contract_id} to {contract.customer.email}")
return {
'success': True,
'contract_id': contract_id,
'recipient': contract.customer.email,
}
except Exception as e:
logger.error(f"Failed to send contract email for {contract_id}: {str(e)}", exc_info=True)
# Retry with exponential backoff
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=3)
def send_contract_reminder(self, contract_id):
"""
Send reminder email for pending contract.
Args:
contract_id: ID of the Contract to remind about
Returns:
dict: Result with success status
"""
from .models import Contract
from django.db import connection
from core.models import Tenant
try:
contract = Contract.objects.select_related('customer').get(id=contract_id)
except Contract.DoesNotExist:
logger.error(f"Contract {contract_id} not found")
return {'success': False, 'error': 'Contract not found'}
# Only send reminder if contract is still pending
if contract.status != 'PENDING':
logger.info(f"Skipping reminder for contract {contract_id} - status is {contract.status}")
return {'success': False, 'skipped': True, 'reason': f'Contract status is {contract.status}'}
# Get tenant info
tenant = None
if hasattr(connection, 'tenant'):
tenant = connection.tenant
else:
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
pass
business_name = tenant.name if tenant else 'SmoothSchedule'
from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
# Calculate days until expiration
days_until_expiry = None
if contract.expires_at:
days_until_expiry = (contract.expires_at - timezone.now()).days
context = {
'contract': contract,
'customer': contract.customer,
'business_name': business_name,
'signing_url': contract.get_signing_url(),
'expires_at': contract.expires_at,
'days_until_expiry': days_until_expiry,
}
subject = f"Reminder: Please Sign {contract.title}"
html_message = render_to_string('contracts/emails/reminder.html', context)
plain_message = render_to_string('contracts/emails/reminder.txt', context)
try:
send_mail(
subject=subject,
message=plain_message,
from_email=from_email,
recipient_list=[contract.customer.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Sent contract reminder for {contract_id} to {contract.customer.email}")
return {'success': True, 'contract_id': contract_id}
except Exception as e:
logger.error(f"Failed to send reminder for {contract_id}: {str(e)}", exc_info=True)
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=3)
def send_contract_signed_emails(self, contract_id):
"""
Send confirmation emails after contract is signed.
Sends to both customer and business owner.
Args:
contract_id: ID of the signed Contract
Returns:
dict: Result with success status
"""
from .models import Contract
from django.db import connection
from core.models import Tenant
from smoothschedule.users.models import User
try:
contract = Contract.objects.select_related('customer', 'signature').get(id=contract_id)
except Contract.DoesNotExist:
logger.error(f"Contract {contract_id} not found")
return {'success': False, 'error': 'Contract not found'}
if contract.status != 'SIGNED':
logger.warning(f"Contract {contract_id} is not signed, skipping confirmation emails")
return {'success': False, 'skipped': True}
# Get tenant info
tenant = None
if hasattr(connection, 'tenant'):
tenant = connection.tenant
else:
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
pass
business_name = tenant.name if tenant else 'SmoothSchedule'
from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
context = {
'contract': contract,
'signature': contract.signature,
'customer': contract.customer,
'business_name': business_name,
}
results = {'customer_sent': False, 'business_sent': False}
# Send to customer
try:
subject = f"Contract Signed: {contract.title}"
html_message = render_to_string('contracts/emails/signed_customer.html', context)
plain_message = render_to_string('contracts/emails/signed_customer.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=from_email,
recipient_list=[contract.customer.email],
html_message=html_message,
fail_silently=False,
)
results['customer_sent'] = True
logger.info(f"Sent signed confirmation to customer {contract.customer.email}")
except Exception as e:
logger.error(f"Failed to send customer confirmation for {contract_id}: {str(e)}")
# Send to business owner(s)
try:
# Get business owners
owners = User.objects.filter(role='owner')
owner_emails = [owner.email for owner in owners if owner.email]
if owner_emails:
subject = f"Contract Signed by {contract.customer.get_full_name() or contract.customer.email}"
html_message = render_to_string('contracts/emails/signed_business.html', context)
plain_message = render_to_string('contracts/emails/signed_business.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=from_email,
recipient_list=owner_emails,
html_message=html_message,
fail_silently=False,
)
results['business_sent'] = True
logger.info(f"Sent signed notification to business owners: {owner_emails}")
except Exception as e:
logger.error(f"Failed to send business notification for {contract_id}: {str(e)}")
return {'success': True, 'results': results}
@shared_task(bind=True, max_retries=3)
def generate_contract_pdf(self, contract_id):
"""
Generate PDF for a signed contract and save to storage.
Args:
contract_id: ID of the signed Contract
Returns:
dict: Result with PDF path
"""
from .models import Contract
from .pdf_service import ContractPDFService
try:
contract = Contract.objects.select_related('customer', 'signature').get(id=contract_id)
except Contract.DoesNotExist:
logger.error(f"Contract {contract_id} not found")
return {'success': False, 'error': 'Contract not found'}
if contract.status != 'SIGNED':
logger.warning(f"Contract {contract_id} is not signed, cannot generate PDF")
return {'success': False, 'error': 'Contract must be signed'}
try:
pdf_path = ContractPDFService.save_contract_pdf(contract)
logger.info(f"Generated PDF for contract {contract_id}: {pdf_path}")
return {
'success': True,
'contract_id': contract_id,
'pdf_path': pdf_path,
}
except Exception as e:
logger.error(f"Failed to generate PDF for {contract_id}: {str(e)}", exc_info=True)
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
@shared_task
def check_expired_contracts():
"""
Periodic task to check for expired contracts and mark them as EXPIRED.
Should run daily.
Returns:
dict: Number of contracts marked as expired
"""
from .models import Contract
now = timezone.now()
# Find pending contracts that have expired
expired_contracts = Contract.objects.filter(
status='PENDING',
expires_at__lte=now,
expires_at__isnull=False,
)
count = expired_contracts.count()
# Mark them as expired
expired_contracts.update(status='EXPIRED')
logger.info(f"Marked {count} contracts as expired")
return {'expired_count': count}
@shared_task
def send_pending_reminders():
"""
Periodic task to send reminders for pending contracts.
Sends reminder 3 days before expiration.
Should run daily.
Returns:
dict: Number of reminders sent
"""
from .models import Contract
# Calculate the reminder window (contracts expiring in 3 days)
now = timezone.now()
reminder_date = now + timedelta(days=3)
# Find pending contracts expiring in approximately 3 days
# Use a 12-hour window around the 3-day mark
contracts_to_remind = Contract.objects.filter(
status='PENDING',
expires_at__gte=reminder_date - timedelta(hours=12),
expires_at__lte=reminder_date + timedelta(hours=12),
expires_at__isnull=False,
).select_related('customer')
count = 0
for contract in contracts_to_remind:
try:
send_contract_reminder.delay(contract.id)
count += 1
except Exception as e:
logger.error(f"Failed to queue reminder for contract {contract.id}: {str(e)}")
logger.info(f"Queued {count} contract reminders")
return {'reminders_queued': count}

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ template.name }} - Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
padding: 0;
}
.preview-banner {
background: #fef3c7;
border: 2px solid #f59e0b;
color: #92400e;
padding: 12px;
text-align: center;
font-weight: bold;
margin-bottom: 24px;
border-radius: 4px;
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
flex: 1;
}
.header-right {
text-align: right;
}
.logo {
max-width: 180px;
max-height: 60px;
margin-bottom: 10px;
}
.business-name {
font-size: 24pt;
font-weight: 700;
color: #1f2937;
margin-bottom: 5px;
}
.contract-title {
font-size: 20pt;
font-weight: 600;
color: #1f2937;
margin: 30px 0 20px 0;
text-align: center;
}
.metadata {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 30px;
}
.metadata-row {
display: flex;
margin-bottom: 8px;
}
.metadata-row:last-child {
margin-bottom: 0;
}
.metadata-label {
font-weight: 600;
color: #6b7280;
width: 180px;
}
.metadata-value {
color: #1f2937;
}
.content {
margin: 30px 0;
line-height: 1.8;
}
.content h1 {
font-size: 16pt;
margin-top: 24px;
margin-bottom: 12px;
color: #1f2937;
}
.content h2 {
font-size: 14pt;
margin-top: 20px;
margin-bottom: 10px;
color: #374151;
}
.content p {
margin-bottom: 12px;
}
.content ul, .content ol {
margin-left: 20px;
margin-bottom: 12px;
}
.signature-section {
margin-top: 40px;
padding: 20px;
background-color: #f3f4f6;
border: 2px dashed #9ca3af;
border-radius: 8px;
}
.signature-section h2 {
font-size: 14pt;
color: #6b7280;
margin-bottom: 15px;
text-align: center;
}
.signature-placeholder {
text-align: center;
color: #9ca3af;
font-style: italic;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 9pt;
color: #6b7280;
}
.template-info {
font-size: 9pt;
color: #6b7280;
}
</style>
</head>
<body>
{% if is_preview %}
<div class="preview-banner">
{{ preview_notice }}
</div>
{% endif %}
<div class="header">
<div class="header-left">
{% if business_logo_url %}
<img src="{{ business_logo_url }}" alt="{{ business_name }}" class="logo">
{% else %}
<div class="business-name">{{ business_name }}</div>
{% endif %}
</div>
<div class="header-right">
<div class="template-info">
<div>Template: <strong>{{ template.name }}</strong></div>
<div>Version: <strong>{{ template.version }}</strong></div>
<div>Scope: <strong>{{ template.get_scope_display }}</strong></div>
</div>
</div>
</div>
<h1 class="contract-title">{{ template.name }}</h1>
<div class="metadata">
<div class="metadata-row">
<span class="metadata-label">Customer:</span>
<span class="metadata-value">John Smith (sample)</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Customer Email:</span>
<span class="metadata-value">john.smith@example.com</span>
</div>
{% if template.scope == 'APPOINTMENT' %}
<div class="metadata-row">
<span class="metadata-label">Appointment:</span>
<span class="metadata-value">Sample Service - (date will be filled in)</span>
</div>
{% endif %}
<div class="metadata-row">
<span class="metadata-label">Contract Created:</span>
<span class="metadata-value">(will be filled when sent)</span>
</div>
</div>
<div class="content">
{{ content_html|safe }}
</div>
<div class="signature-section">
<h2>Signature Section</h2>
<p class="signature-placeholder">
This section will contain:<br>
• Electronic consent checkboxes<br>
• Signer name input<br>
• Full audit trail with IP address, timestamp, and document hash
</p>
</div>
<div class="footer">
<p>
This is a preview of contract template "{{ template.name }}" (v{{ template.version }}).<br>
Variables have been replaced with sample data.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ contract.title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
padding: 0;
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
flex: 1;
}
.header-right {
text-align: right;
}
.logo {
max-width: 180px;
max-height: 60px;
margin-bottom: 10px;
}
.business-name {
font-size: 24pt;
font-weight: 700;
color: #1f2937;
margin-bottom: 5px;
}
.contract-title {
font-size: 20pt;
font-weight: 600;
color: #1f2937;
margin: 30px 0 20px 0;
text-align: center;
}
.metadata {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 30px;
}
.metadata-row {
display: flex;
margin-bottom: 8px;
}
.metadata-row:last-child {
margin-bottom: 0;
}
.metadata-label {
font-weight: 600;
color: #6b7280;
width: 180px;
}
.metadata-value {
color: #1f2937;
}
.content {
margin: 30px 0;
line-height: 1.8;
}
.content h1 {
font-size: 16pt;
margin-top: 24px;
margin-bottom: 12px;
color: #1f2937;
}
.content h2 {
font-size: 14pt;
margin-top: 20px;
margin-bottom: 10px;
color: #374151;
}
.content p {
margin-bottom: 12px;
}
.content ul, .content ol {
margin-left: 20px;
margin-bottom: 12px;
}
.signature-section {
margin-top: 40px;
padding: 20px;
background-color: #f0f9ff;
border: 2px solid #2563eb;
border-radius: 8px;
}
.signature-section h2 {
font-size: 16pt;
color: #1e40af;
margin-bottom: 15px;
text-align: center;
}
.signature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 15px;
}
.signature-field {
margin-bottom: 10px;
}
.signature-label {
font-weight: 600;
color: #1e40af;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.signature-value {
font-size: 11pt;
color: #1f2937;
margin-top: 3px;
}
.signature-checkmark {
font-size: 14pt;
color: #059669;
font-weight: 700;
}
.audit-trail {
margin-top: 40px;
page-break-inside: avoid;
}
.audit-trail h2 {
font-size: 14pt;
color: #1f2937;
margin-bottom: 15px;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 8px;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 20px;
}
.audit-table th {
background-color: #f3f4f6;
color: #374151;
font-weight: 600;
text-align: left;
padding: 10px;
border: 1px solid #d1d5db;
}
.audit-table td {
padding: 8px 10px;
border: 1px solid #e5e7eb;
color: #1f2937;
}
.audit-table tr:nth-child(even) {
background-color: #f9fafb;
}
.legal-notice {
margin-top: 30px;
padding: 15px;
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
font-size: 9pt;
color: #78350f;
line-height: 1.5;
page-break-inside: avoid;
}
.legal-notice-title {
font-weight: 700;
margin-bottom: 8px;
font-size: 10pt;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 9pt;
color: #6b7280;
}
.verification-code {
font-family: "Courier New", monospace;
background-color: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 10pt;
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
{% if business_logo_url %}
<img src="{{ business_logo_url }}" alt="{{ business_name }}" class="logo">
{% else %}
<div class="business-name">{{ business_name }}</div>
{% endif %}
</div>
<div class="header-right">
<div style="font-size: 10pt; color: #6b7280;">
<div>Document ID: <strong>{{ contract.id }}</strong></div>
<div>Generated: <strong>{{ signature.signed_at|date:"M d, Y g:i A" }}</strong></div>
</div>
</div>
</div>
<h1 class="contract-title">{{ contract.title }}</h1>
<div class="metadata">
<div class="metadata-row">
<span class="metadata-label">Customer:</span>
<span class="metadata-value">{{ customer.get_full_name|default:customer.email }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Customer Email:</span>
<span class="metadata-value">{{ customer.email }}</span>
</div>
{% if event %}
<div class="metadata-row">
<span class="metadata-label">Appointment:</span>
<span class="metadata-value">{{ event.title }} - {{ event.start_time|date:"M d, Y g:i A" }}</span>
</div>
{% endif %}
<div class="metadata-row">
<span class="metadata-label">Contract Created:</span>
<span class="metadata-value">{{ contract.created_at|date:"M d, Y g:i A" }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Signed:</span>
<span class="metadata-value">{{ signature.signed_at|date:"M d, Y g:i A" }}</span>
</div>
</div>
<div class="content">
{{ contract.content_html|safe }}
</div>
<div class="signature-section">
<h2><span class="signature-checkmark"></span> Electronically Signed</h2>
<div class="signature-grid">
<div class="signature-field">
<div class="signature-label">Signer Name</div>
<div class="signature-value">{{ signature.signer_name }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Signer Email</div>
<div class="signature-value">{{ signature.signer_email }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Signed At</div>
<div class="signature-value">{{ signature.signed_at|date:"F d, Y g:i:s A T" }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Verification Code</div>
<div class="signature-value">
<span class="verification-code">{{ contract.content_hash|slice:":16" }}</span>
</div>
</div>
</div>
<div class="signature-field" style="margin-top: 15px;">
<div class="signature-label">Electronic Consent</div>
<div class="signature-value">
<span class="signature-checkmark"></span>
{{ signature.consent_text|truncatewords:20 }}
</div>
</div>
</div>
<div class="audit-trail">
<h2>Audit Trail & Verification</h2>
<table class="audit-table">
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Document ID</td>
<td>{{ contract.id }}</td>
</tr>
<tr>
<td>Document Hash (SHA-256)</td>
<td style="font-family: 'Courier New', monospace; font-size: 8pt; word-break: break-all;">
{{ signature.document_hash_at_signing }}
</td>
</tr>
<tr>
<td>Signed At (UTC)</td>
<td>{{ signature.signed_at|date:"Y-m-d H:i:s" }} UTC</td>
</tr>
<tr>
<td>Signer IP Address</td>
<td>{{ signature.ip_address }}</td>
</tr>
{% if geolocation %}
<tr>
<td>Geolocation</td>
<td>{{ geolocation }}</td>
</tr>
{% endif %}
<tr>
<td>User Agent</td>
<td style="font-size: 8pt; word-break: break-word;">{{ signature.user_agent|truncatewords:15 }}</td>
</tr>
<tr>
<td>Verification Code</td>
<td><span class="verification-code">{{ contract.content_hash|slice:":16" }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="legal-notice">
<div class="legal-notice-title">Legal Notice - Electronic Signature</div>
<p>{{ esign_notice }}</p>
<p style="margin-top: 10px;">
This document has been cryptographically signed and verified.
The document hash (SHA-256) ensures the content has not been tampered with since signing.
Any modification to the content would result in a different hash value.
</p>
</div>
<div class="footer">
<p>
This is a legally binding document executed electronically.<br>
For verification, contact {{ business_name }}{% if tenant.contact_email %} at {{ tenant.contact_email }}{% endif %}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,16 @@
"""
URL routing for Contract/E-Signature system.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r"templates", views.ContractTemplateViewSet, basename="contract-template")
router.register(r"requirements", views.ServiceContractRequirementViewSet, basename="contract-requirement")
router.register(r"", views.ContractViewSet, basename="contract")
urlpatterns = [
path("sign/<str:token>/", views.PublicContractSigningView.as_view(), name="contract-sign"),
path("", include(router.urls)),
]

View File

@@ -0,0 +1,430 @@
"""
Views for Contract/E-Signature system.
"""
import hashlib
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.db import connection
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import ContractTemplate, ServiceContractRequirement, Contract, ContractSignature
from .serializers import (
ContractTemplateSerializer, ContractTemplateListSerializer,
ServiceContractRequirementSerializer, ContractSerializer, ContractListSerializer,
PublicContractSerializer, ContractSignatureInputSerializer, CreateContractSerializer
)
def get_client_ip(request):
"""Extract client IP from request"""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0].strip()
else:
ip = request.META.get("REMOTE_ADDR")
return ip
class ContractTemplateViewSet(viewsets.ModelViewSet):
"""
CRUD for contract templates.
Permissions: owner/manager only
"""
queryset = ContractTemplate.objects.all()
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
if self.action == "list":
return ContractTemplateListSerializer
return ContractTemplateSerializer
def get_queryset(self):
qs = super().get_queryset()
status_filter = self.request.query_params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
return qs.order_by("name")
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
@action(detail=True, methods=["post"])
def duplicate(self, request, pk=None):
"""Create a copy of an existing template"""
template = self.get_object()
new_template = ContractTemplate.objects.create(
name=f"{template.name} (Copy)",
description=template.description,
content=template.content,
scope=template.scope,
status=ContractTemplate.Status.DRAFT,
expires_after_days=template.expires_after_days,
created_by=request.user,
)
serializer = ContractTemplateSerializer(new_template)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def new_version(self, request, pk=None):
"""Create a new version of the template"""
template = self.get_object()
template.version += 1
template.version_notes = request.data.get("version_notes", "")
if "content" in request.data:
template.content = request.data["content"]
if "name" in request.data:
template.name = request.data["name"]
if "description" in request.data:
template.description = request.data["description"]
template.save()
serializer = ContractTemplateSerializer(template)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def activate(self, request, pk=None):
"""Activate a draft template"""
template = self.get_object()
template.status = ContractTemplate.Status.ACTIVE
template.save(update_fields=["status", "updated_at"])
return Response({"success": True})
@action(detail=True, methods=["post"])
def archive(self, request, pk=None):
"""Archive a template"""
template = self.get_object()
template.status = ContractTemplate.Status.ARCHIVED
template.save(update_fields=["status", "updated_at"])
return Response({"success": True})
@action(detail=True, methods=["get"])
def preview_pdf(self, request, pk=None):
"""Generate a PDF preview of the template"""
import logging
logger = logging.getLogger(__name__)
from django.http import HttpResponse
from .pdf_service import ContractPDFService, WEASYPRINT_AVAILABLE
if not WEASYPRINT_AVAILABLE:
return Response(
{"error": "PDF generation not available"},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
template = self.get_object()
try:
pdf_bytes = ContractPDFService.generate_template_preview(template, request.user)
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'inline; filename="{template.name}_preview.pdf"'
return response
except Exception as e:
import traceback
logger.error(f"PDF preview error: {e}")
logger.error(traceback.format_exc())
return Response(
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ServiceContractRequirementViewSet(viewsets.ModelViewSet):
"""Manage which contracts are required for which services"""
queryset = ServiceContractRequirement.objects.all()
serializer_class = ServiceContractRequirementSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = super().get_queryset().select_related("service", "template")
service_id = self.request.query_params.get("service")
template_id = self.request.query_params.get("template")
if service_id:
qs = qs.filter(service_id=service_id)
if template_id:
qs = qs.filter(template_id=template_id)
return qs
class ContractViewSet(viewsets.ModelViewSet):
"""
CRUD for contract instances.
Includes sending, viewing, and PDF download.
"""
queryset = Contract.objects.all()
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
if self.action == "list":
return ContractListSerializer
if self.action == "create":
return CreateContractSerializer
return ContractSerializer
def get_queryset(self):
qs = super().get_queryset().select_related(
"customer", "template", "signature", "event"
)
customer_id = self.request.query_params.get("customer")
status_filter = self.request.query_params.get("status")
template_id = self.request.query_params.get("template")
if customer_id:
qs = qs.filter(customer_id=customer_id)
if status_filter:
qs = qs.filter(status=status_filter)
if template_id:
qs = qs.filter(template_id=template_id)
return qs.order_by("-created_at")
def create(self, request, *args, **kwargs):
"""Create a contract from a template"""
serializer = CreateContractSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
template = serializer.validated_data["template"]
customer = serializer.validated_data["customer"]
event = serializer.validated_data.get("event")
send_email = serializer.validated_data.get("send_email", True)
# Render content with variables
content_html = self._render_template(template, customer, event)
content_hash = hashlib.sha256(content_html.encode()).hexdigest()
# Calculate expiration
expires_at = None
if template.expires_after_days:
expires_at = timezone.now() + timezone.timedelta(days=template.expires_after_days)
contract = Contract.objects.create(
template=template,
template_version=template.version,
title=template.name,
content_html=content_html,
content_hash=content_hash,
customer=customer,
event=event if template.scope == ContractTemplate.Scope.APPOINTMENT else None,
expires_at=expires_at,
sent_by=request.user,
)
if send_email:
from .tasks import send_contract_email
send_contract_email.delay(contract.id)
contract.sent_at = timezone.now()
contract.save(update_fields=["sent_at"])
response_serializer = ContractSerializer(contract, context={"request": request})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
def _render_template(self, template, customer, event=None):
"""Render template with variable substitution"""
from core.models import Tenant
tenant = Tenant.objects.get(schema_name=connection.schema_name)
context = {
"CUSTOMER_NAME": customer.get_full_name() or customer.email,
"CUSTOMER_FIRST_NAME": customer.first_name or customer.email.split("@")[0],
"CUSTOMER_LAST_NAME": customer.last_name or "",
"CUSTOMER_EMAIL": customer.email,
"CUSTOMER_PHONE": getattr(customer, "phone", "") or "",
"BUSINESS_NAME": tenant.name,
"BUSINESS_EMAIL": tenant.contact_email or "",
"BUSINESS_PHONE": tenant.phone or "",
"DATE": timezone.now().strftime("%B %d, %Y"),
"YEAR": timezone.now().strftime("%Y"),
}
# Add event-specific variables if available
if event:
context["APPOINTMENT_DATE"] = event.start_time.strftime("%B %d, %Y")
context["APPOINTMENT_TIME"] = event.start_time.strftime("%I:%M %p")
if event.service:
context["SERVICE_NAME"] = event.service.name
content = template.content
for key, value in context.items():
content = content.replace(f"{{{{{key}}}}}", str(value or ""))
return content
@action(detail=True, methods=["post"])
def send(self, request, pk=None):
"""Send contract to customer via email"""
contract = self.get_object()
if contract.status != Contract.Status.PENDING:
return Response(
{"error": "Contract is not pending"},
status=status.HTTP_400_BAD_REQUEST
)
from .tasks import send_contract_email
send_contract_email.delay(contract.id)
contract.sent_at = timezone.now()
contract.save(update_fields=["sent_at"])
return Response({"success": True, "message": "Contract sent"})
@action(detail=True, methods=["post"])
def resend(self, request, pk=None):
"""Resend contract email"""
contract = self.get_object()
if contract.status != Contract.Status.PENDING:
return Response(
{"error": "Contract is not pending"},
status=status.HTTP_400_BAD_REQUEST
)
from .tasks import send_contract_email
send_contract_email.delay(contract.id)
return Response({"success": True, "message": "Contract resent"})
@action(detail=True, methods=["post"])
def void(self, request, pk=None):
"""Void a pending contract"""
contract = self.get_object()
if contract.status != Contract.Status.PENDING:
return Response(
{"error": "Only pending contracts can be voided"},
status=status.HTTP_400_BAD_REQUEST
)
contract.status = Contract.Status.VOIDED
contract.save(update_fields=["status", "updated_at"])
return Response({"success": True})
@action(detail=True, methods=["get"])
def download_pdf(self, request, pk=None):
"""Download signed contract PDF"""
from django.http import FileResponse
from django.core.files.storage import default_storage
contract = self.get_object()
if not contract.pdf_path:
return Response({"error": "PDF not available"}, status=status.HTTP_404_NOT_FOUND)
try:
file = default_storage.open(contract.pdf_path, "rb")
return FileResponse(
file,
as_attachment=True,
filename=f"{contract.title}.pdf"
)
except Exception:
return Response({"error": "PDF not found"}, status=status.HTTP_404_NOT_FOUND)
class PublicContractSigningView(APIView):
"""
Public endpoint for signing contracts (no auth required).
Uses token-based access.
"""
permission_classes = [AllowAny]
def get(self, request, token):
"""Get contract details for signing page"""
contract = get_object_or_404(Contract, signing_token=token)
if contract.status == Contract.Status.SIGNED:
return Response(
{"error": "Contract already signed", "status": "signed"},
status=status.HTTP_400_BAD_REQUEST
)
if contract.status == Contract.Status.VOIDED:
return Response(
{"error": "Contract has been voided", "status": "voided"},
status=status.HTTP_400_BAD_REQUEST
)
if contract.expires_at and timezone.now() > contract.expires_at:
contract.status = Contract.Status.EXPIRED
contract.save(update_fields=["status"])
return Response(
{"error": "Contract has expired", "status": "expired"},
status=status.HTTP_400_BAD_REQUEST
)
serializer = PublicContractSerializer(contract)
return Response(serializer.data)
def post(self, request, token):
"""Sign the contract"""
contract = get_object_or_404(Contract, signing_token=token)
# Validate status
if contract.status != Contract.Status.PENDING:
return Response(
{"error": "Contract cannot be signed", "status": contract.status},
status=status.HTTP_400_BAD_REQUEST
)
# Validate expiration
if contract.expires_at and timezone.now() > contract.expires_at:
contract.status = Contract.Status.EXPIRED
contract.save(update_fields=["status"])
return Response({"error": "Contract has expired"}, status=status.HTTP_400_BAD_REQUEST)
serializer = ContractSignatureInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Validate consent
if not serializer.validated_data["consent_checkbox_checked"]:
return Response(
{"error": "You must check the consent box"},
status=status.HTTP_400_BAD_REQUEST
)
if not serializer.validated_data["electronic_consent_given"]:
return Response(
{"error": "You must consent to electronic records"},
status=status.HTTP_400_BAD_REQUEST
)
# Standard consent text
consent_text = (
"I have read and agree to the terms and conditions outlined in this document. "
"By checking this box, I understand that this constitutes a legal electronic signature "
"under the ESIGN Act (15 U.S.C. section 7001 et seq.) and UETA."
)
electronic_consent_text = (
"I consent to conduct business electronically. I understand that: "
"1. I am agreeing to use electronic records and signatures in place of paper. "
"2. I have the right to receive documents in paper form upon request. "
"3. I can withdraw this consent at any time. "
"4. I need internet access to access these documents. "
"5. I can request a paper copy at any time."
)
# Create signature with audit trail
signature = ContractSignature.objects.create(
contract=contract,
consent_checkbox_checked=True,
consent_text=consent_text,
electronic_consent_given=True,
electronic_consent_text=electronic_consent_text,
signer_name=serializer.validated_data["signer_name"],
signer_email=contract.customer.email,
signed_at=timezone.now(),
ip_address=get_client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", "")[:500],
document_hash_at_signing=contract.content_hash,
latitude=serializer.validated_data.get("latitude"),
longitude=serializer.validated_data.get("longitude"),
)
# Update contract status
contract.status = Contract.Status.SIGNED
contract.save(update_fields=["status", "updated_at"])
# Generate PDF and send confirmation emails asynchronously
from .tasks import generate_contract_pdf, send_contract_signed_emails
generate_contract_pdf.delay(contract.id)
send_contract_signed_emails.delay(contract.id)
return Response({"success": True, "message": "Contract signed successfully"})

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.8 on 2025-12-04 19:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0027_add_deposit_percent_back'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Holiday',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(help_text="Unique identifier (e.g., 'thanksgiving_us', 'christmas')", max_length=50, unique=True)),
('name', models.CharField(max_length=100)),
('country', models.CharField(db_index=True, default='US', help_text='ISO 3166-1 alpha-2 country code', max_length=2)),
('holiday_type', models.CharField(choices=[('FIXED', 'Fixed date'), ('FLOATING', 'Floating (Nth weekday of month)'), ('CALCULATED', 'Calculated (algorithm-based)')], default='FIXED', max_length=20)),
('month', models.PositiveSmallIntegerField(blank=True, help_text='Month (1-12)', null=True)),
('day', models.PositiveSmallIntegerField(blank=True, help_text='Day of month (1-31)', null=True)),
('week_of_month', models.PositiveSmallIntegerField(blank=True, help_text="Week of month (1-4, or 5 for 'last')", null=True)),
('day_of_week', models.PositiveSmallIntegerField(blank=True, help_text='Day of week (0=Monday, 6=Sunday)', null=True)),
('calculation_rule', models.CharField(blank=True, help_text="Calculation rule (e.g., 'easter', 'easter-2' for Good Friday)", max_length=50)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['country', 'name'],
'indexes': [models.Index(fields=['country', 'is_active'], name='schedule_ho_country_b41340_idx')],
},
),
migrations.CreateModel(
name='TimeBlock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text="Display title (e.g., 'Christmas Day', 'Lunch Break')", max_length=200)),
('description', models.TextField(blank=True, help_text='Optional description or reason for block')),
('block_type', models.CharField(choices=[('HARD', 'Hard Block (prevents booking)'), ('SOFT', 'Soft Block (warning only, override allowed)')], default='HARD', help_text='HARD prevents booking; SOFT shows warning but allows override', max_length=10)),
('recurrence_type', models.CharField(choices=[('NONE', 'No recurrence (specific date/range)'), ('WEEKLY', 'Weekly (specific days of week)'), ('MONTHLY', 'Monthly (specific days of month)'), ('YEARLY', 'Yearly (specific days of year)'), ('HOLIDAY', 'Holiday (floating dates)')], db_index=True, default='NONE', max_length=20)),
('start_date', models.DateField(blank=True, help_text='Start date for one-time blocks', null=True)),
('end_date', models.DateField(blank=True, help_text='End date for one-time blocks (same as start for single day)', null=True)),
('all_day', models.BooleanField(default=True, help_text='If true, blocks entire day; if false, uses start/end time')),
('start_time', models.TimeField(blank=True, help_text='Start time (if not all-day)', null=True)),
('end_time', models.TimeField(blank=True, help_text='End time (if not all-day)', null=True)),
('recurrence_pattern', models.JSONField(blank=True, default=dict, help_text='\n Recurrence configuration:\n - WEEKLY: {"days_of_week": [0,1,2]} (0=Mon, 6=Sun)\n - MONTHLY: {"days_of_month": [1, 15]}\n - YEARLY: {"month": 7, "day": 4} or {"month": 12, "day": 25}\n - HOLIDAY: {"holiday_code": "thanksgiving_us"}\n ')),
('recurrence_start', models.DateField(blank=True, help_text='When this recurring block becomes active', null=True)),
('recurrence_end', models.DateField(blank=True, help_text='When this recurring block ends (null = forever)', null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_time_blocks', to=settings.AUTH_USER_MODEL)),
('resource', models.ForeignKey(blank=True, help_text='Specific resource (null = business-level block)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='time_blocks', to='schedule.resource')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['resource', 'is_active'], name='schedule_ti_resourc_1e9a5d_idx'), models.Index(fields=['recurrence_type', 'is_active'], name='schedule_ti_recurre_b7b096_idx'), models.Index(fields=['start_date', 'end_date'], name='schedule_ti_start_d_bba9d9_idx')],
},
),
]

View File

@@ -1478,4 +1478,428 @@ class EmailTemplate(models.Model):
def _append_text_footer(self, text: str) -> str:
"""Append Powered by Smooth Schedule footer to plain text"""
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
return text + footer
return text + footer
class Holiday(models.Model):
"""
Predefined holiday definitions for the holiday picker.
Supports three types:
- FIXED: Same date every year (e.g., Christmas on Dec 25)
- FLOATING: Nth weekday of a month (e.g., Thanksgiving = 4th Thursday of November)
- CALCULATED: Algorithm-based (e.g., Easter)
"""
class Type(models.TextChoices):
FIXED = 'FIXED', 'Fixed date'
FLOATING = 'FLOATING', 'Floating (Nth weekday of month)'
CALCULATED = 'CALCULATED', 'Calculated (algorithm-based)'
code = models.CharField(
max_length=50,
unique=True,
help_text="Unique identifier (e.g., 'thanksgiving_us', 'christmas')"
)
name = models.CharField(max_length=100)
country = models.CharField(
max_length=2,
default='US',
db_index=True,
help_text="ISO 3166-1 alpha-2 country code"
)
holiday_type = models.CharField(
max_length=20,
choices=Type.choices,
default=Type.FIXED
)
# For FIXED holidays (e.g., Dec 25)
month = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Month (1-12)"
)
day = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Day of month (1-31)"
)
# For FLOATING holidays (e.g., "4th Thursday of November")
week_of_month = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Week of month (1-4, or 5 for 'last')"
)
day_of_week = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Day of week (0=Monday, 6=Sunday)"
)
# For CALCULATED holidays (e.g., Easter)
calculation_rule = models.CharField(
max_length=50,
blank=True,
help_text="Calculation rule (e.g., 'easter', 'easter-2' for Good Friday)"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['country', 'name']
indexes = [
models.Index(fields=['country', 'is_active']),
]
def __str__(self):
return f"{self.name} ({self.country})"
def get_date_for_year(self, year):
"""
Calculate the actual date for this holiday in a given year.
Returns:
date object or None if cannot be calculated
"""
from datetime import date, timedelta
if self.holiday_type == self.Type.FIXED:
if self.month and self.day:
try:
return date(year, self.month, self.day)
except ValueError:
return None
elif self.holiday_type == self.Type.FLOATING:
if self.month and self.week_of_month is not None and self.day_of_week is not None:
# Find Nth weekday of month
first_day = date(year, self.month, 1)
first_weekday = first_day.weekday()
# Days until first occurrence of target weekday
days_until = (self.day_of_week - first_weekday) % 7
first_occurrence = first_day + timedelta(days=days_until)
if self.week_of_month == 5: # "Last" occurrence
# Find last occurrence by going to next month and going back
if self.month == 12:
next_month = date(year + 1, 1, 1)
else:
next_month = date(year, self.month + 1, 1)
last_day = next_month - timedelta(days=1)
days_back = (last_day.weekday() - self.day_of_week) % 7
return last_day - timedelta(days=days_back)
else:
return first_occurrence + timedelta(weeks=self.week_of_month - 1)
elif self.holiday_type == self.Type.CALCULATED:
if self.calculation_rule.startswith('easter'):
easter_date = self._calculate_easter(year)
if '+' in self.calculation_rule:
offset = int(self.calculation_rule.split('+')[1])
return easter_date + timedelta(days=offset)
elif '-' in self.calculation_rule:
offset = int(self.calculation_rule.split('-')[1])
return easter_date - timedelta(days=offset)
return easter_date
return None
@staticmethod
def _calculate_easter(year):
"""
Calculate Easter Sunday using the Anonymous Gregorian algorithm.
"""
from datetime import date
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
class TimeBlock(models.Model):
"""
Time blocking model for business closures and resource unavailability.
Supports two levels:
- Business-level: Affects entire business (resource=None)
- Resource-level: Affects specific resource (resource=FK)
Blocks can be one-time or recurring with various patterns.
"""
class BlockType(models.TextChoices):
HARD = 'HARD', 'Hard Block (prevents booking)'
SOFT = 'SOFT', 'Soft Block (warning only, override allowed)'
class RecurrenceType(models.TextChoices):
NONE = 'NONE', 'No recurrence (specific date/range)'
WEEKLY = 'WEEKLY', 'Weekly (specific days of week)'
MONTHLY = 'MONTHLY', 'Monthly (specific days of month)'
YEARLY = 'YEARLY', 'Yearly (specific days of year)'
HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
# Core identification
title = models.CharField(
max_length=200,
help_text="Display title (e.g., 'Christmas Day', 'Lunch Break')"
)
description = models.TextField(
blank=True,
help_text="Optional description or reason for block"
)
# Level determination
resource = models.ForeignKey(
'Resource',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='time_blocks',
help_text="Specific resource (null = business-level block)"
)
# Block behavior
block_type = models.CharField(
max_length=10,
choices=BlockType.choices,
default=BlockType.HARD,
help_text="HARD prevents booking; SOFT shows warning but allows override"
)
# Recurrence configuration
recurrence_type = models.CharField(
max_length=20,
choices=RecurrenceType.choices,
default=RecurrenceType.NONE,
db_index=True
)
# For NONE: specific date range
start_date = models.DateField(
null=True,
blank=True,
help_text="Start date for one-time blocks"
)
end_date = models.DateField(
null=True,
blank=True,
help_text="End date for one-time blocks (same as start for single day)"
)
# Time window (applies to all patterns)
all_day = models.BooleanField(
default=True,
help_text="If true, blocks entire day; if false, uses start/end time"
)
start_time = models.TimeField(
null=True,
blank=True,
help_text="Start time (if not all-day)"
)
end_time = models.TimeField(
null=True,
blank=True,
help_text="End time (if not all-day)"
)
# Recurrence patterns (JSON)
recurrence_pattern = models.JSONField(
default=dict,
blank=True,
help_text="""
Recurrence configuration:
- WEEKLY: {"days_of_week": [0,1,2]} (0=Mon, 6=Sun)
- MONTHLY: {"days_of_month": [1, 15]}
- YEARLY: {"month": 7, "day": 4} or {"month": 12, "day": 25}
- HOLIDAY: {"holiday_code": "thanksgiving_us"}
"""
)
# Recurrence bounds
recurrence_start = models.DateField(
null=True,
blank=True,
help_text="When this recurring block becomes active"
)
recurrence_end = models.DateField(
null=True,
blank=True,
help_text="When this recurring block ends (null = forever)"
)
# Status
is_active = models.BooleanField(default=True, db_index=True)
# Audit
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='created_time_blocks'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['resource', 'is_active']),
models.Index(fields=['recurrence_type', 'is_active']),
models.Index(fields=['start_date', 'end_date']),
]
def __str__(self):
level = f"Resource: {self.resource.name}" if self.resource else "Business-level"
return f"{self.title} ({level})"
@property
def is_business_level(self):
"""Check if this is a business-level block (affects all resources)."""
return self.resource is None
def blocks_date(self, check_date):
"""
Check if this block applies to a given date.
Args:
check_date: date object to check
Returns:
bool: True if date is blocked
"""
from datetime import date
if not self.is_active:
return False
# Check recurrence bounds
if self.recurrence_start and check_date < self.recurrence_start:
return False
if self.recurrence_end and check_date > self.recurrence_end:
return False
if self.recurrence_type == self.RecurrenceType.NONE:
# One-time block: check date range
if self.start_date and self.end_date:
return self.start_date <= check_date <= self.end_date
elif self.start_date:
return check_date == self.start_date
return False
elif self.recurrence_type == self.RecurrenceType.WEEKLY:
# Check if check_date's weekday is in the pattern
days = self.recurrence_pattern.get('days_of_week', [])
return check_date.weekday() in days
elif self.recurrence_type == self.RecurrenceType.MONTHLY:
# Check if check_date's day of month is in the pattern
days = self.recurrence_pattern.get('days_of_month', [])
return check_date.day in days
elif self.recurrence_type == self.RecurrenceType.YEARLY:
# Check if month and day match
month = self.recurrence_pattern.get('month')
day = self.recurrence_pattern.get('day')
if month and day:
return check_date.month == month and check_date.day == day
return False
elif self.recurrence_type == self.RecurrenceType.HOLIDAY:
# Check if date matches the holiday for this year
holiday_code = self.recurrence_pattern.get('holiday_code')
if holiday_code:
try:
holiday = Holiday.objects.get(code=holiday_code, is_active=True)
holiday_date = holiday.get_date_for_year(check_date.year)
return holiday_date == check_date
except Holiday.DoesNotExist:
return False
return False
return False
def blocks_datetime_range(self, start_dt, end_dt):
"""
Check if this block overlaps with a datetime range.
Args:
start_dt: datetime - start of range to check
end_dt: datetime - end of range to check
Returns:
bool: True if any part of the range is blocked
"""
from datetime import datetime, time, timedelta
# First check if any date in the range is blocked
current_date = start_dt.date()
end_date = end_dt.date()
while current_date <= end_date:
if self.blocks_date(current_date):
# Date is blocked, now check time window
if self.all_day:
return True
else:
# Check time overlap
if self.start_time and self.end_time:
block_start = datetime.combine(current_date, self.start_time)
block_end = datetime.combine(current_date, self.end_time)
# Check overlap: start_dt < block_end AND end_dt > block_start
if start_dt < block_end and end_dt > block_start:
return True
current_date += timedelta(days=1)
return False
def get_blocked_dates_in_range(self, range_start, range_end):
"""
Generate all blocked dates within a date range.
Used for calendar visualization.
Args:
range_start: date - start of range
range_end: date - end of range
Returns:
list of dicts with 'date', 'start_time', 'end_time', 'all_day'
"""
from datetime import timedelta
blocked_dates = []
current_date = range_start
while current_date <= range_end:
if self.blocks_date(current_date):
blocked_dates.append({
'date': current_date,
'start_time': self.start_time,
'end_time': self.end_time,
'all_day': self.all_day,
'title': self.title,
'block_type': self.block_type,
'block_id': self.id,
'is_business_level': self.is_business_level,
})
current_date += timedelta(days=1)
return blocked_dates

View File

@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .services import AvailabilityService
from smoothschedule.users.models import User
@@ -488,29 +488,39 @@ class EventSerializer(serializers.ModelSerializer):
# CRITICAL: This enforces concurrency limits
event_id = self.instance.id if self.instance else None
availability_errors = []
soft_block_warnings = []
for resource_id in resource_ids:
try:
resource = Resource.objects.get(id=resource_id, is_active=True)
except Resource.DoesNotExist:
availability_errors.append(f"Resource ID {resource_id} not found or inactive")
continue
# Call the availability service
is_available, reason = AvailabilityService.check_availability(
is_available, reason, warnings = AvailabilityService.check_availability(
resource=resource,
start_time=start_time,
end_time=end_time,
exclude_event_id=event_id
)
if not is_available:
availability_errors.append(f"{resource.name}: {reason}")
else:
# Collect soft block warnings (these can be overridden)
soft_block_warnings.extend(warnings)
if availability_errors:
raise serializers.ValidationError({
'non_field_errors': availability_errors
})
# Store soft warnings for the view layer to handle (e.g., show confirmation dialog)
# The frontend can pass force_override=true to proceed despite soft blocks
if soft_block_warnings and not self.context.get('force_override', False):
# Add warnings to context so they can be included in response
self.context['soft_block_warnings'] = soft_block_warnings
return attrs
@@ -1108,4 +1118,351 @@ class EmailTemplatePreviewSerializer(serializers.Serializer):
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True, required=False, default='')
text_content = serializers.CharField(allow_blank=True, required=False, default='')
context = serializers.DictField(required=False, default=dict)
context = serializers.DictField(required=False, default=dict)
# =============================================================================
# Time Blocking System Serializers
# =============================================================================
class HolidaySerializer(serializers.ModelSerializer):
"""Serializer for Holiday reference data"""
next_occurrence = serializers.SerializerMethodField()
class Meta:
model = Holiday
fields = [
'code', 'name', 'country', 'holiday_type',
'month', 'day', 'week_of_month', 'day_of_week',
'calculation_rule', 'is_active', 'next_occurrence',
]
read_only_fields = fields # Holidays are reference data, not editable via API
def get_next_occurrence(self, obj):
"""Get the next occurrence date for this holiday"""
from datetime import date
today = date.today()
current_year_date = obj.get_date_for_year(today.year)
if current_year_date and current_year_date >= today:
return current_year_date.isoformat()
# If this year's date has passed, get next year's
next_year_date = obj.get_date_for_year(today.year + 1)
return next_year_date.isoformat() if next_year_date else None
class HolidayListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for holiday dropdowns"""
class Meta:
model = Holiday
fields = ['code', 'name', 'country']
class TimeBlockSerializer(serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField()
holiday_name = serializers.SerializerMethodField()
conflict_count = serializers.SerializerMethodField()
class Meta:
model = TimeBlock
fields = [
'id', 'title', 'description',
'resource', 'resource_name', 'level',
'block_type', 'recurrence_type',
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_pattern', 'pattern_display', 'holiday_name',
'recurrence_start', 'recurrence_end',
'is_active', 'created_by', 'created_by_name',
'conflict_count', 'created_at', 'updated_at',
]
read_only_fields = ['created_by', 'created_at', 'updated_at']
def get_created_by_name(self, obj):
if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email
return None
def get_level(self, obj):
"""Return 'business' if no resource, otherwise 'resource'"""
return 'business' if obj.resource is None else 'resource'
def get_pattern_display(self, obj):
"""Get human-readable description of the recurrence pattern"""
if obj.recurrence_type == TimeBlock.RecurrenceType.NONE:
if obj.start_date == obj.end_date:
return obj.start_date.strftime('%B %d, %Y') if obj.start_date else 'One-time'
return f"{obj.start_date} to {obj.end_date}" if obj.start_date else 'One-time'
if obj.recurrence_type == TimeBlock.RecurrenceType.WEEKLY:
days = obj.recurrence_pattern.get('days_of_week', [])
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
selected = [day_names[d] for d in days if 0 <= d <= 6]
return f"Weekly on {', '.join(selected)}" if selected else 'Weekly'
if obj.recurrence_type == TimeBlock.RecurrenceType.MONTHLY:
days = obj.recurrence_pattern.get('days_of_month', [])
if days:
day_strs = [self._ordinal(d) for d in days]
return f"Monthly on the {', '.join(day_strs)}"
return 'Monthly'
if obj.recurrence_type == TimeBlock.RecurrenceType.YEARLY:
month = obj.recurrence_pattern.get('month')
day = obj.recurrence_pattern.get('day')
if month and day:
from calendar import month_name
return f"Yearly on {month_name[month]} {day}"
return 'Yearly'
if obj.recurrence_type == TimeBlock.RecurrenceType.HOLIDAY:
holiday_code = obj.recurrence_pattern.get('holiday_code')
if holiday_code:
try:
holiday = Holiday.objects.get(code=holiday_code)
return f"Holiday: {holiday.name}"
except Holiday.DoesNotExist:
return f"Holiday: {holiday_code}"
return 'Holiday'
return str(obj.recurrence_type)
def _ordinal(self, n):
"""Convert number to ordinal string (1 -> 1st, 2 -> 2nd, etc.)"""
if 11 <= n <= 13:
suffix = 'th'
else:
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')
return f"{n}{suffix}"
def get_holiday_name(self, obj):
"""Get holiday name if this is a holiday block"""
if obj.recurrence_type != TimeBlock.RecurrenceType.HOLIDAY:
return None
holiday_code = obj.recurrence_pattern.get('holiday_code')
if holiday_code:
try:
holiday = Holiday.objects.get(code=holiday_code)
return holiday.name
except Holiday.DoesNotExist:
return None
return None
def get_conflict_count(self, obj):
"""Count events that conflict with this block"""
# This is an expensive operation, only compute on detail view
request = self.context.get('request')
if request and request.method == 'GET':
# Only compute for detail endpoints (single object)
view = self.context.get('view')
if view and hasattr(view, 'action') and view.action == 'retrieve':
return self._count_conflicts(obj)
return None
def _count_conflicts(self, obj):
"""Count events that would conflict with this time block"""
from datetime import date, timedelta
today = date.today()
end_date = today + timedelta(days=90) # Look 90 days ahead
blocked_dates = obj.get_blocked_dates_in_range(today, end_date)
if not blocked_dates:
return 0
# Count events on blocked dates
from schedule.models import Event
event_count = 0
for blocked_date in blocked_dates:
# Check if any events fall on this date
events = Event.objects.filter(
start_time__date=blocked_date,
status__in=['SCHEDULED', 'CONFIRMED']
)
if obj.resource:
# Filter to events with this resource as participant
from django.contrib.contenttypes.models import ContentType
resource_ct = ContentType.objects.get_for_model(Resource)
events = events.filter(
participants__content_type=resource_ct,
participants__object_id=obj.resource_id
)
event_count += events.count()
return event_count
def validate(self, attrs):
"""Validate time block configuration"""
recurrence_type = attrs.get('recurrence_type', TimeBlock.RecurrenceType.NONE)
pattern = attrs.get('recurrence_pattern', {})
# Validate NONE type requires dates
if recurrence_type == TimeBlock.RecurrenceType.NONE:
if not attrs.get('start_date'):
raise serializers.ValidationError({
'start_date': 'Start date is required for one-time blocks'
})
# Validate WEEKLY requires days_of_week
if recurrence_type == TimeBlock.RecurrenceType.WEEKLY:
days = pattern.get('days_of_week', [])
if not days:
raise serializers.ValidationError({
'recurrence_pattern': 'days_of_week is required for weekly blocks'
})
for d in days:
if not isinstance(d, int) or d < 0 or d > 6:
raise serializers.ValidationError({
'recurrence_pattern': 'days_of_week must be integers 0-6'
})
# Validate MONTHLY requires days_of_month
if recurrence_type == TimeBlock.RecurrenceType.MONTHLY:
days = pattern.get('days_of_month', [])
if not days:
raise serializers.ValidationError({
'recurrence_pattern': 'days_of_month is required for monthly blocks'
})
for d in days:
if not isinstance(d, int) or d < 1 or d > 31:
raise serializers.ValidationError({
'recurrence_pattern': 'days_of_month must be integers 1-31'
})
# Validate YEARLY requires month and day
if recurrence_type == TimeBlock.RecurrenceType.YEARLY:
if not pattern.get('month') or not pattern.get('day'):
raise serializers.ValidationError({
'recurrence_pattern': 'month and day are required for yearly blocks'
})
month = pattern.get('month')
day = pattern.get('day')
if not (1 <= month <= 12):
raise serializers.ValidationError({
'recurrence_pattern': 'month must be 1-12'
})
if not (1 <= day <= 31):
raise serializers.ValidationError({
'recurrence_pattern': 'day must be 1-31'
})
# Validate HOLIDAY requires holiday_code
if recurrence_type == TimeBlock.RecurrenceType.HOLIDAY:
holiday_code = pattern.get('holiday_code')
if not holiday_code:
raise serializers.ValidationError({
'recurrence_pattern': 'holiday_code is required for holiday blocks'
})
if not Holiday.objects.filter(code=holiday_code, is_active=True).exists():
raise serializers.ValidationError({
'recurrence_pattern': f"Holiday '{holiday_code}' not found"
})
# Validate time fields
all_day = attrs.get('all_day', True)
if not all_day:
if not attrs.get('start_time'):
raise serializers.ValidationError({
'start_time': 'Start time is required when all_day is false'
})
if not attrs.get('end_time'):
raise serializers.ValidationError({
'end_time': 'End time is required when all_day is false'
})
if attrs.get('start_time') >= attrs.get('end_time'):
raise serializers.ValidationError({
'end_time': 'End time must be after start time'
})
# Check permission for hard blocks if user is staff
request = self.context.get('request')
if request and request.user.is_authenticated:
user = request.user
block_type = attrs.get('block_type', TimeBlock.BlockType.SOFT)
resource = attrs.get('resource')
# Staff creating hard blocks need permission
if user.role == 'TENANT_STAFF':
if block_type == TimeBlock.BlockType.HARD:
permissions = user.permissions or {}
if not permissions.get('can_create_hard_blocks', False):
raise serializers.ValidationError({
'block_type': 'You do not have permission to create hard blocks'
})
# Staff can only create blocks for their own resource
if resource:
if not hasattr(user, 'resource') or user.resource != resource:
raise serializers.ValidationError({
'resource': 'Staff can only create blocks for their own resource'
})
else:
# Staff cannot create business-level blocks
permissions = user.permissions or {}
if not permissions.get('can_create_business_blocks', False):
raise serializers.ValidationError({
'resource': 'Staff cannot create business-level blocks'
})
return attrs
def create(self, validated_data):
"""Set created_by from request context"""
request = self.context.get('request')
if request and hasattr(request, 'user') and request.user.is_authenticated:
validated_data['created_by'] = request.user
return super().create(validated_data)
class TimeBlockListSerializer(serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField()
class Meta:
model = TimeBlock
fields = [
'id', 'title', 'description', 'resource', 'resource_name', 'level',
'block_type', 'recurrence_type', 'start_date', 'end_date',
'all_day', 'start_time', 'end_time', 'recurrence_pattern',
'recurrence_start', 'recurrence_end', 'pattern_display',
'is_active', 'created_at',
]
def get_level(self, obj):
return 'business' if obj.resource is None else 'resource'
def get_pattern_display(self, obj):
"""Simple pattern description for list view"""
if obj.recurrence_type == TimeBlock.RecurrenceType.NONE:
return 'One-time'
return obj.recurrence_type.replace('_', ' ').title()
class BlockedDateSerializer(serializers.Serializer):
"""Serializer for blocked date responses (calendar view)"""
date = serializers.DateField()
block_type = serializers.CharField()
title = serializers.CharField()
resource_id = serializers.IntegerField(allow_null=True)
all_day = serializers.BooleanField()
start_time = serializers.TimeField(allow_null=True)
end_time = serializers.TimeField(allow_null=True)
time_block_id = serializers.IntegerField()
class CheckConflictsSerializer(serializers.Serializer):
"""Input serializer for checking block conflicts"""
recurrence_type = serializers.ChoiceField(choices=TimeBlock.RecurrenceType.choices)
recurrence_pattern = serializers.JSONField(required=False, default=dict)
start_date = serializers.DateField(required=False, allow_null=True)
end_date = serializers.DateField(required=False, allow_null=True)
resource_id = serializers.IntegerField(required=False, allow_null=True)
all_day = serializers.BooleanField(default=True)
start_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)

View File

@@ -1,80 +1,158 @@
"""
Availability Service - Resource capacity checking with concurrency management
Availability Service - Resource capacity checking with concurrency management and time blocks
"""
from django.contrib.contenttypes.models import ContentType
from .models import Event, Participant, Resource
from django.db.models import Q
from .models import Event, Participant, Resource, TimeBlock
class AvailabilityService:
"""
Service for checking resource availability with concurrency limits.
Service for checking resource availability with concurrency limits and time blocks.
CRITICAL Features:
- Handles max_concurrent_events==0 as unlimited capacity
- Filters out CANCELED events to prevent ghost bookings
- Uses correct overlap logic: start < query_end AND end > query_start
- Checks business-level and resource-level time blocks
- Returns soft block warnings separately from hard blocks
"""
@staticmethod
def check_availability(resource, start_time, end_time, exclude_event_id=None):
"""
Check if resource has capacity for a new/updated event.
Args:
resource (Resource): The resource to check
start_time (datetime): Proposed event start
end_time (datetime): Proposed event end
exclude_event_id (int, optional): Event ID to exclude (when updating)
Returns:
tuple: (is_available: bool, reason: str)
tuple: (is_available: bool, reason: str, soft_block_warnings: list)
- is_available: False if hard-blocked or capacity exceeded
- reason: Human-readable explanation
- soft_block_warnings: List of soft block warnings (can be overridden)
"""
# Step 1: Calculate search window with buffer
soft_block_warnings = []
# Step 1: Check time blocks (business-level first, then resource-level)
block_result = AvailabilityService._check_time_blocks(
resource, start_time, end_time
)
if block_result['hard_blocked']:
return False, block_result['reason'], []
soft_block_warnings.extend(block_result['soft_warnings'])
# Step 2: Calculate search window with buffer
query_start = start_time - resource.buffer_duration
query_end = end_time + resource.buffer_duration
# Step 2: Find all events for this resource
# Step 3: Find all events for this resource
resource_content_type = ContentType.objects.get_for_model(Resource)
resource_participants = Participant.objects.filter(
content_type=resource_content_type,
object_id=resource.id,
role=Participant.Role.RESOURCE
).select_related('event')
# Step 3: Filter for overlapping events
# Step 4: Filter for overlapping events
overlapping_events = []
for participant in resource_participants:
event = participant.event
# Skip if this is the event being updated
# CRITICAL: Convert exclude_event_id to int for comparison (frontend may send string)
if exclude_event_id and event.id == int(exclude_event_id):
continue
# CRITICAL: Skip cancelled events (prevents ghost bookings)
if event.status == Event.Status.CANCELED:
continue
# CRITICAL: Check overlap using correct logic
# Overlap exists when: event.start < query_end AND event.end > query_start
if event.start_time < query_end and event.end_time > query_start:
overlapping_events.append(event)
current_count = len(overlapping_events)
# Step 4: Check capacity limit
# Step 5: Check capacity limit
# CRITICAL: Handle infinite capacity (0 = unlimited)
if resource.max_concurrent_events == 0:
return True, "Unlimited capacity resource"
return True, "Unlimited capacity resource", soft_block_warnings
# Check if we've hit the limit
if current_count >= resource.max_concurrent_events:
return False, (
f"Resource capacity exceeded. "
f"{current_count}/{resource.max_concurrent_events} slots occupied."
)
), []
# Available!
return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)"
return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)", soft_block_warnings
@staticmethod
def _check_time_blocks(resource, start_time, end_time):
"""
Check if a time period is blocked by any time blocks.
Checks both business-level blocks (resource=null) and resource-level blocks.
Business-level blocks are checked first as they apply to all resources.
Args:
resource (Resource): The resource to check
start_time (datetime): Proposed event start
end_time (datetime): Proposed event end
Returns:
dict: {
'hard_blocked': bool,
'reason': str,
'soft_warnings': list of warning strings
}
"""
result = {
'hard_blocked': False,
'reason': '',
'soft_warnings': []
}
# Get active time blocks (business-level + resource-level)
blocks = TimeBlock.objects.filter(
Q(resource__isnull=True) | Q(resource=resource),
is_active=True
).order_by('resource') # Business blocks first (null sorts first)
for block in blocks:
# Check if this block applies to the requested datetime range
if block.blocks_datetime_range(start_time, end_time):
if block.block_type == TimeBlock.BlockType.HARD:
# Hard block - immediately return unavailable
level = "Business closed" if block.resource is None else f"{resource.name} unavailable"
result['hard_blocked'] = True
result['reason'] = f"{level}: {block.title}"
return result
else:
# Soft block - add warning but continue
level = "Business advisory" if block.resource is None else f"{resource.name} advisory"
result['soft_warnings'].append(f"{level}: {block.title}")
return result
@staticmethod
def check_availability_simple(resource, start_time, end_time, exclude_event_id=None):
"""
Simple availability check that returns just (bool, str) for backwards compatibility.
Use check_availability() for full soft block warning support.
"""
is_available, reason, _ = AvailabilityService.check_availability(
resource, start_time, end_time, exclude_event_id
)
return is_available, reason

View File

@@ -8,7 +8,8 @@ from .views import (
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
GlobalEventPluginViewSet, EmailTemplateViewSet
GlobalEventPluginViewSet, EmailTemplateViewSet,
HolidayViewSet, TimeBlockViewSet
)
from .export_views import ExportViewSet
@@ -31,6 +32,8 @@ router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
router.register(r'global-event-plugins', GlobalEventPluginViewSet, basename='globaleventplugin')
router.register(r'email-templates', EmailTemplateViewSet, basename='emailtemplate')
router.register(r'export', ExportViewSet, basename='export')
router.register(r'holidays', HolidayViewSet, basename='holiday')
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
# URL patterns
urlpatterns = [

View File

@@ -8,14 +8,16 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.decorators import action
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
EventPluginSerializer, GlobalEventPluginSerializer,
EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer
EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer,
HolidaySerializer, HolidayListSerializer,
TimeBlockSerializer, TimeBlockListSerializer, BlockedDateSerializer, CheckConflictsSerializer
)
from .models import Service
from core.permissions import HasQuota
@@ -1739,4 +1741,410 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
# Return all presets organized by category
return Response({
'presets': get_all_presets()
})
})
# =============================================================================
# Time Blocking System ViewSets
# =============================================================================
class HolidayViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for viewing holidays.
Holidays are reference data seeded by the system and cannot be modified
through the API. Use the `seed_holidays` management command to populate.
Endpoints:
- GET /api/holidays/ - List all holidays
- GET /api/holidays/{code}/ - Get holiday details
- GET /api/holidays/dates/ - Get holiday dates for a year
"""
queryset = Holiday.objects.filter(is_active=True)
serializer_class = HolidaySerializer
permission_classes = [IsAuthenticated]
lookup_field = 'code'
def get_queryset(self):
"""Filter by country if specified"""
queryset = super().get_queryset()
country = self.request.query_params.get('country')
if country:
queryset = queryset.filter(country=country.upper())
return queryset.order_by('country', 'name')
def get_serializer_class(self):
if self.action == 'list':
return HolidayListSerializer
return HolidaySerializer
@action(detail=False, methods=['get'])
def dates(self, request):
"""
Get all holiday dates for a specific year.
Query params:
- year: The year to get dates for (default: current year)
- country: Filter by country code (default: all)
Response:
{
"year": 2025,
"holidays": [
{"code": "new_years_day", "name": "New Year's Day", "date": "2025-01-01"},
...
]
}
"""
from datetime import date
year = request.query_params.get('year')
if year:
try:
year = int(year)
except ValueError:
return Response(
{'error': 'Invalid year'},
status=status.HTTP_400_BAD_REQUEST
)
else:
year = date.today().year
queryset = self.get_queryset()
holidays = []
for holiday in queryset:
holiday_date = holiday.get_date_for_year(year)
if holiday_date:
holidays.append({
'code': holiday.code,
'name': holiday.name,
'date': holiday_date.isoformat(),
})
# Sort by date
holidays.sort(key=lambda h: h['date'])
return Response({
'year': year,
'holidays': holidays
})
class TimeBlockViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing time blocks.
Time blocks allow businesses to block off time for various purposes:
- Business closures (holidays, vacations)
- Resource-specific unavailability (maintenance, personal time)
- Recurring patterns (weekly lunch breaks, monthly meetings)
Block Types:
- HARD: Prevents booking entirely
- SOFT: Shows warning but allows override
Block Levels:
- Business-level: resource=null, applies to all resources
- Resource-level: resource=ID, applies only to that resource
Endpoints:
- GET /api/time-blocks/ - List blocks (filterable)
- POST /api/time-blocks/ - Create block
- GET /api/time-blocks/{id}/ - Get block details
- PATCH /api/time-blocks/{id}/ - Update block
- DELETE /api/time-blocks/{id}/ - Delete block
- GET /api/time-blocks/blocked-dates/ - Get expanded dates for calendar
- POST /api/time-blocks/check-conflicts/ - Preview conflicts before creating
- GET /api/time-blocks/my-blocks/ - Staff view of their own blocks
"""
queryset = TimeBlock.objects.select_related('resource', 'created_by').all()
serializer_class = TimeBlockSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Filter time blocks based on query parameters"""
queryset = super().get_queryset()
user = self.request.user
# Filter by level (business or resource)
level = self.request.query_params.get('level')
if level == 'business':
queryset = queryset.filter(resource__isnull=True)
elif level == 'resource':
queryset = queryset.filter(resource__isnull=False)
# Filter by specific resource
resource_id = self.request.query_params.get('resource_id')
if resource_id:
queryset = queryset.filter(resource_id=resource_id)
# Filter by block type
block_type = self.request.query_params.get('block_type')
if block_type:
queryset = queryset.filter(block_type=block_type.upper())
# Filter by recurrence type
recurrence_type = self.request.query_params.get('recurrence_type')
if recurrence_type:
queryset = queryset.filter(recurrence_type=recurrence_type.upper())
# Filter by active status
is_active = self.request.query_params.get('is_active')
if is_active is not None:
queryset = queryset.filter(is_active=is_active.lower() == 'true')
# Staff can only see their own resource's blocks + business blocks
if user.role == 'TENANT_STAFF':
from django.db.models import Q
if hasattr(user, 'resource') and user.resource:
queryset = queryset.filter(
Q(resource__isnull=True) | # Business blocks
Q(resource=user.resource) # Their resource blocks
)
else:
# Staff without linked resource only see business blocks
queryset = queryset.filter(resource__isnull=True)
return queryset.order_by('-created_at')
def get_serializer_class(self):
if self.action == 'list':
return TimeBlockListSerializer
return TimeBlockSerializer
@action(detail=False, methods=['get'])
def blocked_dates(self, request):
"""
Get expanded blocked dates for calendar visualization.
Query params:
- start_date: Start of range (required, YYYY-MM-DD)
- end_date: End of range (required, YYYY-MM-DD)
- resource_id: Filter to specific resource (optional)
- include_business: Include business-level blocks (default: true)
Response:
{
"blocked_dates": [
{
"date": "2025-01-01",
"block_type": "HARD",
"title": "New Year's Day",
"resource_id": null,
"all_day": true,
"start_time": null,
"end_time": null,
"time_block_id": 123
},
...
]
}
"""
from datetime import datetime
start_date_str = request.query_params.get('start_date')
end_date_str = request.query_params.get('end_date')
if not start_date_str or not end_date_str:
return Response(
{'error': 'start_date and end_date are required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
return Response(
{'error': 'Invalid date format. Use YYYY-MM-DD'},
status=status.HTTP_400_BAD_REQUEST
)
# Get active blocks
queryset = self.get_queryset().filter(is_active=True)
resource_id = request.query_params.get('resource_id')
include_business = request.query_params.get('include_business', 'true').lower() == 'true'
from django.db.models import Q
if resource_id:
if include_business:
queryset = queryset.filter(
Q(resource__isnull=True) | Q(resource_id=resource_id)
)
else:
queryset = queryset.filter(resource_id=resource_id)
elif not include_business:
queryset = queryset.filter(resource__isnull=False)
blocked_dates = []
for block in queryset:
dates = block.get_blocked_dates_in_range(start_date, end_date)
for blocked_info in dates:
blocked_dates.append({
'date': blocked_info['date'].isoformat(),
'block_type': block.block_type,
'title': block.title,
'resource_id': block.resource_id,
'all_day': block.all_day,
'start_time': block.start_time.isoformat() if block.start_time else None,
'end_time': block.end_time.isoformat() if block.end_time else None,
'time_block_id': block.id,
})
# Sort by date
blocked_dates.sort(key=lambda d: (d['date'], d['resource_id'] or 0))
return Response({
'blocked_dates': blocked_dates,
'start_date': start_date_str,
'end_date': end_date_str,
})
@action(detail=False, methods=['post'])
def check_conflicts(self, request):
"""
Check for conflicts before creating a time block.
Request body:
{
"recurrence_type": "NONE",
"recurrence_pattern": {},
"start_date": "2025-01-15",
"end_date": "2025-01-15",
"resource_id": null,
"all_day": true
}
Response:
{
"has_conflicts": true,
"conflict_count": 3,
"conflicts": [
{
"event_id": 123,
"title": "Appointment - John Doe",
"start_time": "2025-01-15T10:00:00",
"end_time": "2025-01-15T11:00:00"
},
...
]
}
"""
serializer = CheckConflictsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
from datetime import date, timedelta
# Create a temporary TimeBlock to use its date calculation methods
temp_block = TimeBlock(
recurrence_type=data['recurrence_type'],
recurrence_pattern=data.get('recurrence_pattern', {}),
start_date=data.get('start_date'),
end_date=data.get('end_date'),
all_day=data.get('all_day', True),
start_time=data.get('start_time'),
end_time=data.get('end_time'),
)
# Get blocked dates for next 90 days
today = date.today()
check_end = today + timedelta(days=90)
blocked_dates = temp_block.get_blocked_dates_in_range(today, check_end)
if not blocked_dates:
return Response({
'has_conflicts': False,
'conflict_count': 0,
'conflicts': []
})
# Find conflicting events
resource_id = data.get('resource_id')
conflicts = []
for blocked_date in blocked_dates:
events = Event.objects.filter(
start_time__date=blocked_date,
status__in=['SCHEDULED', 'CONFIRMED']
)
if resource_id:
from django.contrib.contenttypes.models import ContentType
resource_ct = ContentType.objects.get_for_model(Resource)
events = events.filter(
participants__content_type=resource_ct,
participants__object_id=resource_id
)
for event in events[:10]: # Limit to 10 per date
conflicts.append({
'event_id': event.id,
'title': event.title,
'start_time': event.start_time.isoformat(),
'end_time': event.end_time.isoformat(),
})
return Response({
'has_conflicts': len(conflicts) > 0,
'conflict_count': len(conflicts),
'conflicts': conflicts[:20], # Limit total to 20
})
@action(detail=False, methods=['get'])
def my_blocks(self, request):
"""
Get time blocks for the current staff member's resource.
This endpoint is for staff members to view their own availability.
Returns both their resource blocks and business-level blocks.
Response includes blocks organized by type for easy display.
"""
user = request.user
# Check if user is staff with a linked resource
if not hasattr(user, 'resource') or not user.resource:
return Response({
'business_blocks': [],
'my_blocks': [],
'message': 'You do not have a linked resource'
})
from django.db.models import Q
# Get business blocks
business_blocks = TimeBlock.objects.filter(
resource__isnull=True,
is_active=True
).order_by('-created_at')
# Get blocks for user's resource
my_blocks = TimeBlock.objects.filter(
resource=user.resource,
is_active=True
).order_by('-created_at')
return Response({
'business_blocks': TimeBlockListSerializer(business_blocks, many=True).data,
'my_blocks': TimeBlockListSerializer(my_blocks, many=True).data,
'resource_id': user.resource.id,
'resource_name': user.resource.name,
})
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
"""Toggle the is_active status of a time block"""
block = self.get_object()
block.is_active = not block.is_active
block.save(update_fields=['is_active', 'updated_at'])
return Response({
'id': block.id,
'is_active': block.is_active,
'message': f"Block {'activated' if block.is_active else 'deactivated'}"
})

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder: Contract Signature Required</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">Reminder: Signature Required</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your signature is still needed for a contract from {{ business_name }}</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ customer_first_name|default:customer_name }},</p>
<p>This is a friendly reminder that <strong>{{ business_name }}</strong> is still waiting for your signature on a contract.</p>
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h2 style="color: #92400e; font-size: 18px; margin-top: 0;">{{ contract_title }}</h2>
{% if expires_at %}
<p style="color: #92400e; margin: 10px 0 0 0;">
<strong>Please sign by:</strong> {{ expires_at|date:"F j, Y" }}
</p>
{% endif %}
</div>
<p>Please take a moment to review and sign the contract using the link below.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ signing_url }}" style="display: inline-block; background: #f59e0b; color: white; padding: 14px 40px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Review & Sign Contract</a>
</div>
<p style="color: #6b7280; font-size: 14px; text-align: center; margin: 20px 0;">
Or copy and paste this link into your browser:<br>
<a href="{{ signing_url }}" style="color: #f59e0b; word-break: break-all;">{{ signing_url }}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
If you have any questions about this contract, please contact {{ business_name }} directly.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
Reminder: Signature Required for {{ contract_title }}
Hi {{ customer_first_name|default:customer_name }},
This is a friendly reminder that {{ business_name }} is still waiting for your signature on a contract.
CONTRACT DETAILS
----------------
Title: {{ contract_title }}
{% if expires_at %}Please sign by: {{ expires_at|date:"F j, Y" }}{% endif %}
Please take a moment to review and sign the contract using the link below.
Review & Sign Contract: {{ signing_url }}
If you have any questions about this contract, please contact {{ business_name }} directly.
---
{{ business_name }}

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contract Signature Required</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">Signature Required</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">{{ business_name }} has sent you a contract to review and sign</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ customer_first_name|default:customer_name }},</p>
<p><strong>{{ business_name }}</strong> has sent you a contract that requires your signature.</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h2 style="color: #1f2937; font-size: 18px; margin-top: 0;">{{ contract_title }}</h2>
{% if expires_at %}
<p style="color: #6b7280; margin: 10px 0 0 0;">
<strong>Please sign by:</strong> {{ expires_at|date:"F j, Y" }}
</p>
{% endif %}
</div>
<p>Please review the contract carefully and sign it electronically using the link below.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ signing_url }}" style="display: inline-block; background: #4f46e5; color: white; padding: 14px 40px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Review & Sign Contract</a>
</div>
<p style="color: #6b7280; font-size: 14px; text-align: center; margin: 20px 0;">
Or copy and paste this link into your browser:<br>
<a href="{{ signing_url }}" style="color: #4f46e5; word-break: break-all;">{{ signing_url }}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
If you have any questions about this contract, please contact {{ business_name }} directly.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
Signature Required: {{ contract_title }}
Hi {{ customer_first_name|default:customer_name }},
{{ business_name }} has sent you a contract that requires your signature.
CONTRACT DETAILS
----------------
Title: {{ contract_title }}
{% if expires_at %}Please sign by: {{ expires_at|date:"F j, Y" }}{% endif %}
Please review the contract carefully and sign it electronically using the link below.
Review & Sign Contract: {{ signing_url }}
If you have any questions about this contract, please contact {{ business_name }} directly.
---
{{ business_name }}

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contract Signed by Customer</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">Contract Signed</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">A customer has signed a contract</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ staff_name }},</p>
<p>Great news! <strong>{{ customer_name }}</strong> has signed a contract.</p>
<div style="background: #d1fae5; border: 1px solid #10b981; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h2 style="color: #065f46; font-size: 18px; margin-top: 0;">{{ contract_title }}</h2>
<div style="margin-top: 15px;">
<div style="color: #065f46; margin-bottom: 8px;">
<strong>Customer:</strong> {{ customer_name }}
</div>
<div style="color: #065f46;">
<strong>Signed on:</strong> {{ signed_at|date:"F j, Y \a\t g:i A" }}
</div>
</div>
</div>
<p>The signed contract is now available in your contracts dashboard.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
This is an automated notification from {{ business_name }}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
Contract Signed by Customer
Hi {{ staff_name }},
Great news\! {{ customer_name }} has signed a contract.
CONTRACT DETAILS
----------------
Title: {{ contract_title }}
Customer: {{ customer_name }}
Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
The signed contract is now available in your contracts dashboard.
---
This is an automated notification from {{ business_name }}.

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contract Signed Successfully</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">Contract Signed Successfully</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your signature has been recorded</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ customer_first_name|default:customer_name }},</p>
<p>Thank you for signing the contract with <strong>{{ business_name }}</strong>. Your signature has been successfully recorded.</p>
<div style="background: #d1fae5; border: 1px solid #10b981; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h2 style="color: #065f46; font-size: 18px; margin-top: 0;">{{ contract_title }}</h2>
<p style="color: #065f46; margin: 10px 0 0 0;">
<strong>Signed on:</strong> {{ signed_at|date:"F j, Y \a\t g:i A" }}
</p>
</div>
<p>A copy of the signed contract has been provided for your records. {{ business_name }} also has a copy on file.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
If you have any questions, please contact {{ business_name }}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
Contract Signed Successfully
Hi {{ customer_first_name|default:customer_name }},
Thank you for signing the contract with {{ business_name }}. Your signature has been successfully recorded.
CONTRACT DETAILS
----------------
Title: {{ contract_title }}
Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
A copy of the signed contract has been provided for your records. {{ business_name }} also has a copy on file.
If you have any questions, please contact {{ business_name }}.
---
{{ business_name }}

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #d97706;">Reminder: Contract Signature Required</h2>
<p>Hello {{ customer.get_full_name|default:customer.email }},</p>
<p>This is a reminder to sign the following contract:</p>
<div style="background-color: #fef3c7; border-left: 4px solid #d97706; padding: 15px; margin: 20px 0;">
<strong>{{ contract.title }}</strong>
</div>
{% if days_until_expiry %}
<p style="color: #d97706; font-weight: bold;">
This contract will expire in {{ days_until_expiry }} day{{ days_until_expiry|pluralize }}.
</p>
{% endif %}
<p>
<a href="{{ signing_url }}"
style="display: inline-block; background-color: #d97706; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
Sign Contract Now
</a>
</p>
<p>Thank you,<br>{{ business_name }}</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="font-size: 12px; color: #6b7280;">
This is an automated reminder from {{ business_name }}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,10 @@
Hello {{ customer.get_full_name|default:customer.email }},
This is a reminder to sign the following contract: {{ contract.title }}
Sign here: {{ signing_url }}
{% if days_until_expiry %}This contract will expire in {{ days_until_expiry }} day{{ days_until_expiry|pluralize }}.{% endif %}
Thank you,
{{ business_name }}

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #059669;">Contract Signed</h2>
<p>A contract has been successfully signed by a customer.</p>
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 0 0 10px 0;"><strong style="color: #2563eb;">Contract:</strong> {{ contract.title }}</p>
<p style="margin: 0 0 10px 0;"><strong style="color: #2563eb;">Customer:</strong> {{ customer.get_full_name|default:customer.email }}</p>
<p style="margin: 0 0 10px 0;"><strong style="color: #2563eb;">Email:</strong> {{ customer.email }}</p>
<p style="margin: 0 0 10px 0;"><strong style="color: #2563eb;">Signed By:</strong> {{ signature.signer_name }}</p>
<p style="margin: 0;"><strong style="color: #2563eb;">Signed At:</strong> {{ signature.signed_at|date:"F d, Y \a\t g:i A" }}</p>
</div>
<p>The signed contract PDF has been generated and is available in the system.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="font-size: 12px; color: #6b7280;">
This is an automated notification from {{ business_name }}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
A contract has been signed\!
Contract: {{ contract.title }}
Signed by: {{ signature.signer_name }} ({{ signature.signer_email }})
Signed at: {{ signature.signed_at|date:"F d, Y \a\t g:i A" }}
Customer: {{ customer.get_full_name|default:customer.email }}
Email: {{ customer.email }}
The signed contract PDF is available in the system.
---
{{ business_name }}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #059669;">Contract Successfully Signed</h2>
<p>Hello {{ customer.get_full_name|default:customer.email }},</p>
<p>Thank you for signing <strong>{{ contract.title }}</strong>.</p>
<div style="background-color: #d1fae5; border-left: 4px solid #059669; padding: 15px; margin: 20px 0;">
<p style="margin: 0;"><strong>Signed:</strong> {{ signature.signed_at|date:"F d, Y \a\t g:i A" }}</p>
<p style="margin: 5px 0 0 0;"><strong>Signer:</strong> {{ signature.signer_name }}</p>
</div>
<p>A copy of the signed contract has been saved to your account and is available for download.</p>
<p>If you have any questions, please contact {{ business_name }}.</p>
<p>Thank you,<br>{{ business_name }}</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="font-size: 12px; color: #6b7280;">
This is an automated confirmation from {{ business_name }}.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
Hello {{ customer.get_full_name|default:customer.email }},
Thank you for signing {{ contract.title }}.
Your signature was recorded on {{ signature.signed_at|date:"F d, Y \a\t g:i A" }}.
A copy of the signed contract has been saved to your account.
If you have any questions, please contact {{ business_name }}.
Thank you,
{{ business_name }}

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Contract Signature Required</h2>
<p>Hello {{ customer.get_full_name|default:customer.email }},</p>
<p>Please review and sign the following contract:</p>
<div style="background-color: #f3f4f6; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0;">
<strong>{{ contract.title }}</strong>
</div>
<p>
<a href="{{ signing_url }}"
style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
Sign Contract
</a>
</p>
{% if expires_at %}
<p style="color: #d97706; font-size: 14px;">
<strong>Note:</strong> This signing link will expire on {{ expires_at|date:"F d, Y \a\t g:i A" }}.
</p>
{% endif %}
<p>If you have any questions, please contact {{ business_name }}.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="font-size: 12px; color: #6b7280;">
This is an automated message from {{ business_name }}. Please do not reply to this email.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
Hello {{ customer.get_full_name|default:customer.email }},
Please review and sign the following contract: {{ contract.title }}
To sign this contract, please visit:
{{ signing_url }}
{% if expires_at %}This signing link will expire on {{ expires_at|date:"F d, Y \a\t g:i A" }}.{% endif %}
If you have any questions, please contact {{ business_name }}.
Thank you,
{{ business_name }}

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ template.name }} - Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
padding: 0;
}
.preview-banner {
background: #fef3c7;
border: 2px solid #f59e0b;
color: #92400e;
padding: 12px;
text-align: center;
font-weight: bold;
margin-bottom: 24px;
border-radius: 4px;
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
flex: 1;
}
.header-right {
text-align: right;
}
.logo {
max-width: 180px;
max-height: 60px;
margin-bottom: 10px;
}
.business-name {
font-size: 24pt;
font-weight: 700;
color: #1f2937;
margin-bottom: 5px;
}
.contract-title {
font-size: 20pt;
font-weight: 600;
color: #1f2937;
margin: 30px 0 20px 0;
text-align: center;
}
.metadata {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 30px;
}
.metadata-row {
display: flex;
margin-bottom: 8px;
}
.metadata-row:last-child {
margin-bottom: 0;
}
.metadata-label {
font-weight: 600;
color: #6b7280;
width: 180px;
}
.metadata-value {
color: #1f2937;
}
.content {
margin: 30px 0;
line-height: 1.8;
}
.content h1 {
font-size: 16pt;
margin-top: 24px;
margin-bottom: 12px;
color: #1f2937;
}
.content h2 {
font-size: 14pt;
margin-top: 20px;
margin-bottom: 10px;
color: #374151;
}
.content p {
margin-bottom: 12px;
}
.content ul, .content ol {
margin-left: 20px;
margin-bottom: 12px;
}
.signature-section {
margin-top: 40px;
padding: 20px;
background-color: #f3f4f6;
border: 2px dashed #9ca3af;
border-radius: 8px;
}
.signature-section h2 {
font-size: 14pt;
color: #6b7280;
margin-bottom: 15px;
text-align: center;
}
.signature-placeholder {
text-align: center;
color: #9ca3af;
font-style: italic;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 9pt;
color: #6b7280;
}
.template-info {
font-size: 9pt;
color: #6b7280;
}
</style>
</head>
<body>
{% if is_preview %}
<div class="preview-banner">
{{ preview_notice }}
</div>
{% endif %}
<div class="header">
<div class="header-left">
{% if business_logo_url %}
<img src="{{ business_logo_url }}" alt="{{ business_name }}" class="logo">
{% else %}
<div class="business-name">{{ business_name }}</div>
{% endif %}
</div>
<div class="header-right">
<div class="template-info">
<div>Template: <strong>{{ template.name }}</strong></div>
<div>Version: <strong>{{ template.version }}</strong></div>
<div>Scope: <strong>{{ template.get_scope_display }}</strong></div>
</div>
</div>
</div>
<h1 class="contract-title">{{ template.name }}</h1>
<div class="metadata">
<div class="metadata-row">
<span class="metadata-label">Customer:</span>
<span class="metadata-value">John Smith (sample)</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Customer Email:</span>
<span class="metadata-value">john.smith@example.com</span>
</div>
{% if template.scope == 'APPOINTMENT' %}
<div class="metadata-row">
<span class="metadata-label">Appointment:</span>
<span class="metadata-value">Sample Service - (date will be filled in)</span>
</div>
{% endif %}
<div class="metadata-row">
<span class="metadata-label">Contract Created:</span>
<span class="metadata-value">(will be filled when sent)</span>
</div>
</div>
<div class="content">
{{ content_html|safe }}
</div>
<div class="signature-section">
<h2>Signature Section</h2>
<p class="signature-placeholder">
This section will contain:<br>
• Electronic consent checkboxes<br>
• Signer name input<br>
• Full audit trail with IP address, timestamp, and document hash
</p>
</div>
<div class="footer">
<p>
This is a preview of contract template "{{ template.name }}" (v{{ template.version }}).<br>
Variables have been replaced with sample data.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ contract.title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
padding: 0;
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
flex: 1;
}
.header-right {
text-align: right;
}
.logo {
max-width: 180px;
max-height: 60px;
margin-bottom: 10px;
}
.business-name {
font-size: 24pt;
font-weight: 700;
color: #1f2937;
margin-bottom: 5px;
}
.contract-title {
font-size: 20pt;
font-weight: 600;
color: #1f2937;
margin: 30px 0 20px 0;
text-align: center;
}
.metadata {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 30px;
}
.metadata-row {
display: flex;
margin-bottom: 8px;
}
.metadata-row:last-child {
margin-bottom: 0;
}
.metadata-label {
font-weight: 600;
color: #6b7280;
width: 180px;
}
.metadata-value {
color: #1f2937;
}
.content {
margin: 30px 0;
line-height: 1.8;
}
.content h1 {
font-size: 16pt;
margin-top: 24px;
margin-bottom: 12px;
color: #1f2937;
}
.content h2 {
font-size: 14pt;
margin-top: 20px;
margin-bottom: 10px;
color: #374151;
}
.content p {
margin-bottom: 12px;
}
.content ul, .content ol {
margin-left: 20px;
margin-bottom: 12px;
}
.signature-section {
margin-top: 40px;
padding: 20px;
background-color: #f0f9ff;
border: 2px solid #2563eb;
border-radius: 8px;
}
.signature-section h2 {
font-size: 16pt;
color: #1e40af;
margin-bottom: 15px;
text-align: center;
}
.signature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 15px;
}
.signature-field {
margin-bottom: 10px;
}
.signature-label {
font-weight: 600;
color: #1e40af;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.signature-value {
font-size: 11pt;
color: #1f2937;
margin-top: 3px;
}
.signature-checkmark {
font-size: 14pt;
color: #059669;
font-weight: 700;
}
.audit-trail {
margin-top: 40px;
page-break-inside: avoid;
}
.audit-trail h2 {
font-size: 14pt;
color: #1f2937;
margin-bottom: 15px;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 8px;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 20px;
}
.audit-table th {
background-color: #f3f4f6;
color: #374151;
font-weight: 600;
text-align: left;
padding: 10px;
border: 1px solid #d1d5db;
}
.audit-table td {
padding: 8px 10px;
border: 1px solid #e5e7eb;
color: #1f2937;
}
.audit-table tr:nth-child(even) {
background-color: #f9fafb;
}
.legal-notice {
margin-top: 30px;
padding: 15px;
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
font-size: 9pt;
color: #78350f;
line-height: 1.5;
page-break-inside: avoid;
}
.legal-notice-title {
font-weight: 700;
margin-bottom: 8px;
font-size: 10pt;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 9pt;
color: #6b7280;
}
.verification-code {
font-family: "Courier New", monospace;
background-color: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 10pt;
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
{% if business_logo_url %}
<img src="{{ business_logo_url }}" alt="{{ business_name }}" class="logo">
{% else %}
<div class="business-name">{{ business_name }}</div>
{% endif %}
</div>
<div class="header-right">
<div style="font-size: 10pt; color: #6b7280;">
<div>Document ID: <strong>{{ contract.id }}</strong></div>
<div>Generated: <strong>{{ signature.signed_at|date:"M d, Y g:i A" }}</strong></div>
</div>
</div>
</div>
<h1 class="contract-title">{{ contract.title }}</h1>
<div class="metadata">
<div class="metadata-row">
<span class="metadata-label">Customer:</span>
<span class="metadata-value">{{ customer.get_full_name|default:customer.email }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Customer Email:</span>
<span class="metadata-value">{{ customer.email }}</span>
</div>
{% if event %}
<div class="metadata-row">
<span class="metadata-label">Appointment:</span>
<span class="metadata-value">{{ event.title }} - {{ event.start_time|date:"M d, Y g:i A" }}</span>
</div>
{% endif %}
<div class="metadata-row">
<span class="metadata-label">Contract Created:</span>
<span class="metadata-value">{{ contract.created_at|date:"M d, Y g:i A" }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Signed:</span>
<span class="metadata-value">{{ signature.signed_at|date:"M d, Y g:i A" }}</span>
</div>
</div>
<div class="content">
{{ contract.content_html|safe }}
</div>
<div class="signature-section">
<h2><span class="signature-checkmark"></span> Electronically Signed</h2>
<div class="signature-grid">
<div class="signature-field">
<div class="signature-label">Signer Name</div>
<div class="signature-value">{{ signature.signer_name }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Signer Email</div>
<div class="signature-value">{{ signature.signer_email }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Signed At</div>
<div class="signature-value">{{ signature.signed_at|date:"F d, Y g:i:s A T" }}</div>
</div>
<div class="signature-field">
<div class="signature-label">Verification Code</div>
<div class="signature-value">
<span class="verification-code">{{ contract.content_hash|slice:":16" }}</span>
</div>
</div>
</div>
<div class="signature-field" style="margin-top: 15px;">
<div class="signature-label">Electronic Consent</div>
<div class="signature-value">
<span class="signature-checkmark"></span>
{{ signature.consent_text|truncatewords:20 }}
</div>
</div>
</div>
<div class="audit-trail">
<h2>Audit Trail & Verification</h2>
<table class="audit-table">
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Document ID</td>
<td>{{ contract.id }}</td>
</tr>
<tr>
<td>Document Hash (SHA-256)</td>
<td style="font-family: 'Courier New', monospace; font-size: 8pt; word-break: break-all;">
{{ signature.document_hash_at_signing }}
</td>
</tr>
<tr>
<td>Signed At (UTC)</td>
<td>{{ signature.signed_at|date:"Y-m-d H:i:s" }} UTC</td>
</tr>
<tr>
<td>Signer IP Address</td>
<td>{{ signature.ip_address }}</td>
</tr>
{% if geolocation %}
<tr>
<td>Geolocation</td>
<td>{{ geolocation }}</td>
</tr>
{% endif %}
<tr>
<td>User Agent</td>
<td style="font-size: 8pt; word-break: break-word;">{{ signature.user_agent|truncatewords:15 }}</td>
</tr>
<tr>
<td>Verification Code</td>
<td><span class="verification-code">{{ contract.content_hash|slice:":16" }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="legal-notice">
<div class="legal-notice-title">Legal Notice - Electronic Signature</div>
<p>{{ esign_notice }}</p>
<p style="margin-top: 10px;">
This document has been cryptographically signed and verified.
The document hash (SHA-256) ensures the content has not been tampered with since signing.
Any modification to the content would result in a different hash value.
</p>
</div>
<div class="footer">
<p>
This is a legally binding document executed electronically.<br>
For verification, contact {{ business_name }}{% if tenant.contact_email %} at {{ tenant.contact_email }}{% endif %}.
</p>
</div>
</body>
</html>