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:
@@ -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={
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal file
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal 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;
|
||||
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal file
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal 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;
|
||||
388
frontend/src/hooks/useContracts.ts
Normal file
388
frontend/src/hooks/useContracts.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Time Block Management Hooks
|
||||
*
|
||||
* Provides hooks for managing time blocks and holidays.
|
||||
* Time blocks allow businesses to block off time for closures, holidays,
|
||||
* resource unavailability, and recurring patterns.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
TimeBlock,
|
||||
TimeBlockListItem,
|
||||
BlockedDate,
|
||||
Holiday,
|
||||
TimeBlockConflictCheck,
|
||||
MyBlocksResponse,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
} from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
// =============================================================================
|
||||
|
||||
export interface TimeBlockFilters {
|
||||
level?: 'business' | 'resource';
|
||||
resource_id?: string;
|
||||
block_type?: BlockType;
|
||||
recurrence_type?: RecurrenceType;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface BlockedDatesParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
resource_id?: string;
|
||||
include_business?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimeBlockData {
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string | null;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
recurrence_start?: string;
|
||||
recurrence_end?: string;
|
||||
}
|
||||
|
||||
export interface CheckConflictsData {
|
||||
recurrence_type: RecurrenceType;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
resource_id?: string | null;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Time Block Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch time blocks with optional filters
|
||||
*/
|
||||
export const useTimeBlocks = (filters?: TimeBlockFilters) => {
|
||||
return useQuery<TimeBlockListItem[]>({
|
||||
queryKey: ['time-blocks', filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.level) params.append('level', filters.level);
|
||||
if (filters?.resource_id) params.append('resource_id', filters.resource_id);
|
||||
if (filters?.block_type) params.append('block_type', filters.block_type);
|
||||
if (filters?.recurrence_type) params.append('recurrence_type', filters.recurrence_type);
|
||||
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/?${params}`);
|
||||
return data.map((block: any) => ({
|
||||
...block,
|
||||
id: String(block.id),
|
||||
resource: block.resource ? String(block.resource) : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single time block
|
||||
*/
|
||||
export const useTimeBlock = (id: string) => {
|
||||
return useQuery<TimeBlock>({
|
||||
queryKey: ['time-blocks', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/time-blocks/${id}/`);
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id),
|
||||
resource: data.resource ? String(data.resource) : null,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get blocked dates for calendar visualization
|
||||
*/
|
||||
export const useBlockedDates = (params: BlockedDatesParams) => {
|
||||
return useQuery<BlockedDate[]>({
|
||||
queryKey: ['blocked-dates', params],
|
||||
queryFn: async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
});
|
||||
if (params.resource_id) queryParams.append('resource_id', params.resource_id);
|
||||
if (params.include_business !== undefined) {
|
||||
queryParams.append('include_business', String(params.include_business));
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/blocked_dates/?${queryParams}`);
|
||||
return data.blocked_dates.map((block: any) => ({
|
||||
...block,
|
||||
resource_id: block.resource_id ? String(block.resource_id) : null,
|
||||
time_block_id: String(block.time_block_id),
|
||||
}));
|
||||
},
|
||||
enabled: !!params.start_date && !!params.end_date,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get time blocks for the current staff member
|
||||
*/
|
||||
export const useMyBlocks = () => {
|
||||
return useQuery<MyBlocksResponse>({
|
||||
queryKey: ['my-blocks'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/time-blocks/my_blocks/');
|
||||
return {
|
||||
business_blocks: data.business_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
my_blocks: data.my_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
resource_id: String(data.resource_id),
|
||||
resource_name: data.resource_name,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a time block
|
||||
*/
|
||||
export const useCreateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (blockData: CreateTimeBlockData) => {
|
||||
const payload = {
|
||||
...blockData,
|
||||
resource: blockData.resource ? parseInt(blockData.resource) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/', payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a time block
|
||||
*/
|
||||
export const useUpdateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<CreateTimeBlockData> }) => {
|
||||
const payload: any = { ...updates };
|
||||
if (updates.resource !== undefined) {
|
||||
payload.resource = updates.resource ? parseInt(updates.resource) : null;
|
||||
}
|
||||
const { data } = await apiClient.patch(`/time-blocks/${id}/`, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a time block
|
||||
*/
|
||||
export const useDeleteTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/time-blocks/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle a time block's active status
|
||||
*/
|
||||
export const useToggleTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/toggle/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for conflicts before creating a time block
|
||||
*/
|
||||
export const useCheckConflicts = () => {
|
||||
return useMutation<TimeBlockConflictCheck, Error, CheckConflictsData>({
|
||||
mutationFn: async (checkData) => {
|
||||
const payload = {
|
||||
...checkData,
|
||||
resource_id: checkData.resource_id ? parseInt(checkData.resource_id) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/check_conflicts/', payload);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Holiday Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch holidays
|
||||
*/
|
||||
export const useHolidays = (country?: string) => {
|
||||
return useQuery<Holiday[]>({
|
||||
queryKey: ['holidays', country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single holiday by code
|
||||
*/
|
||||
export const useHoliday = (code: string) => {
|
||||
return useQuery<Holiday>({
|
||||
queryKey: ['holidays', code],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/holidays/${code}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!code,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get holiday dates for a specific year
|
||||
*/
|
||||
export const useHolidayDates = (year?: number, country?: string) => {
|
||||
return useQuery<{ year: number; holidays: { code: string; name: string; date: string }[] }>({
|
||||
queryKey: ['holiday-dates', year, country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.append('year', String(year));
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/dates/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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}}!",
|
||||
|
||||
258
frontend/src/pages/ContractSigning.tsx
Normal file
258
frontend/src/pages/ContractSigning.tsx
Normal 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;
|
||||
527
frontend/src/pages/ContractTemplates.tsx
Normal file
527
frontend/src/pages/ContractTemplates.tsx
Normal 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> <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;
|
||||
892
frontend/src/pages/Contracts.tsx
Normal file
892
frontend/src/pages/Contracts.tsx
Normal 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> <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;
|
||||
@@ -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} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
499
frontend/src/pages/HelpTimeBlocks.tsx
Normal file
499
frontend/src/pages/HelpTimeBlocks.tsx
Normal 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;
|
||||
789
frontend/src/pages/MyAvailability.tsx
Normal file
789
frontend/src/pages/MyAvailability.tsx
Normal 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;
|
||||
@@ -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>);
|
||||
|
||||
555
frontend/src/pages/TimeBlocks.tsx
Normal file
555
frontend/src/pages/TimeBlocks.tsx
Normal 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;
|
||||
@@ -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 */}
|
||||
{/* ============================================== */}
|
||||
|
||||
299
frontend/src/pages/help/HelpContracts.tsx
Normal file
299
frontend/src/pages/help/HelpContracts.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
118
smoothschedule/contracts/README_PDF.md
Normal file
118
smoothschedule/contracts/README_PDF.md
Normal 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
|
||||
},
|
||||
}
|
||||
```
|
||||
0
smoothschedule/contracts/__init__.py
Normal file
0
smoothschedule/contracts/__init__.py
Normal file
3
smoothschedule/contracts/admin.py
Normal file
3
smoothschedule/contracts/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
smoothschedule/contracts/apps.py
Normal file
6
smoothschedule/contracts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContractsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'contracts'
|
||||
115
smoothschedule/contracts/migrations/0001_initial.py
Normal file
115
smoothschedule/contracts/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
0
smoothschedule/contracts/migrations/__init__.py
Normal file
0
smoothschedule/contracts/migrations/__init__.py
Normal file
265
smoothschedule/contracts/models.py
Normal file
265
smoothschedule/contracts/models.py
Normal 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}"
|
||||
299
smoothschedule/contracts/pdf_service.py
Normal file
299
smoothschedule/contracts/pdf_service.py
Normal 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
|
||||
195
smoothschedule/contracts/serializers.py
Normal file
195
smoothschedule/contracts/serializers.py
Normal 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
|
||||
376
smoothschedule/contracts/tasks.py
Normal file
376
smoothschedule/contracts/tasks.py
Normal 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}
|
||||
@@ -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>
|
||||
380
smoothschedule/contracts/templates/contracts/pdf_template.html
Normal file
380
smoothschedule/contracts/templates/contracts/pdf_template.html
Normal 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>
|
||||
3
smoothschedule/contracts/tests.py
Normal file
3
smoothschedule/contracts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
smoothschedule/contracts/urls.py
Normal file
16
smoothschedule/contracts/urls.py
Normal 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)),
|
||||
]
|
||||
430
smoothschedule/contracts/views.py
Normal file
430
smoothschedule/contracts/views.py
Normal 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"})
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'}"
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}.
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
40
smoothschedule/templates/contracts/emails/reminder.html
Normal file
40
smoothschedule/templates/contracts/emails/reminder.html
Normal 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>
|
||||
10
smoothschedule/templates/contracts/emails/reminder.txt
Normal file
10
smoothschedule/templates/contracts/emails/reminder.txt
Normal 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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
230
smoothschedule/templates/contracts/pdf_preview_template.html
Normal file
230
smoothschedule/templates/contracts/pdf_preview_template.html
Normal 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>
|
||||
380
smoothschedule/templates/contracts/pdf_template.html
Normal file
380
smoothschedule/templates/contracts/pdf_template.html
Normal 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>
|
||||
Reference in New Issue
Block a user