feat(time-blocks): Add comprehensive time blocking system with contracts

- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-04 17:19:12 -05:00
parent cf91bae24f
commit 8d0cc1e90a
63 changed files with 11863 additions and 61 deletions

View File

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

View File

@@ -20,9 +20,13 @@ const routeToHelpPath: Record<string, string> = {
'/services': '/help/services',
'/resources': '/help/resources',
'/staff': '/help/staff',
'/time-blocks': '/help/time-blocks',
'/my-availability': '/help/time-blocks',
'/messages': '/help/messages',
'/tickets': '/help/ticketing',
'/payments': '/help/payments',
'/contracts': '/help/contracts',
'/contracts/templates': '/help/contracts',
'/plugins': '/help/plugins',
'/plugins/marketplace': '/help/plugins',
'/plugins/my-plugins': '/help/plugins',

View File

@@ -15,6 +15,8 @@ import {
HelpCircle,
Clock,
Plug,
FileSignature,
CalendarOff,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -121,6 +123,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
/>
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/my-availability"
icon={CalendarOff}
label={t('nav.myAvailability', 'My Availability')}
isCollapsed={isCollapsed}
/>
)}
</SidebarSection>
{/* Manage Section - Staff+ */}
@@ -145,12 +155,26 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
/>
{canViewAdminPages && (
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
/>
<>
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
</>
)}
</SidebarSection>
)}

View File

@@ -0,0 +1,247 @@
/**
* TimeBlockCalendarOverlay - Renders time block overlays on the scheduler calendar
*
* Shows blocked time periods with visual styling:
* - Hard blocks: Red with diagonal stripes, 50% opacity
* - Soft blocks: Yellow with dotted border, 30% opacity
* - Business blocks: Full-width across the lane
* - Resource blocks: Only on matching resource lane
*/
import React, { useMemo, useState } from 'react';
import { BlockedDate, BlockType } from '../../types';
interface TimeBlockCalendarOverlayProps {
blockedDates: BlockedDate[];
resourceId: string;
viewDate: Date;
zoomLevel: number;
pixelsPerMinute: number;
startHour: number;
dayWidth: number;
laneHeight: number;
days: Date[];
onDayClick?: (day: Date) => void;
}
interface TimeBlockTooltipProps {
block: BlockedDate;
position: { x: number; y: number };
}
const TimeBlockTooltip: React.FC<TimeBlockTooltipProps> = ({ block, position }) => {
return (
<div
className="fixed z-[100] px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg max-w-xs pointer-events-none"
style={{
left: position.x + 10,
top: position.y - 40,
}}
>
<div className="font-semibold">{block.title}</div>
<div className="text-xs text-gray-300 mt-1">
{block.block_type === 'HARD' ? 'Hard Block' : 'Soft Block'}
{block.all_day ? ' (All Day)' : ` (${block.start_time} - ${block.end_time})`}
</div>
</div>
);
};
const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
blockedDates,
resourceId,
viewDate,
zoomLevel,
pixelsPerMinute,
startHour,
dayWidth,
laneHeight,
days,
onDayClick,
}) => {
const [hoveredBlock, setHoveredBlock] = useState<{ block: BlockedDate; position: { x: number; y: number } } | null>(null);
// Filter blocks for this resource (includes business-level blocks where resource_id is null)
const relevantBlocks = useMemo(() => {
return blockedDates.filter(
(block) => block.resource_id === null || block.resource_id === resourceId
);
}, [blockedDates, resourceId]);
// Calculate block positions for each day
const blockOverlays = useMemo(() => {
const overlays: Array<{
block: BlockedDate;
left: number;
width: number;
dayIndex: number;
}> = [];
relevantBlocks.forEach((block) => {
// Parse date string as local date, not UTC
// "2025-12-06" should be Dec 6 in local timezone, not UTC
const [year, month, dayNum] = block.date.split('-').map(Number);
const blockDate = new Date(year, month - 1, dayNum);
blockDate.setHours(0, 0, 0, 0);
// Find which day this block falls on
days.forEach((day, dayIndex) => {
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
if (blockDate.getTime() === dayStart.getTime()) {
let left: number;
let width: number;
if (block.all_day) {
// Full day block
left = dayIndex * dayWidth;
width = dayWidth;
} else if (block.start_time && block.end_time) {
// Partial day block
const [startHours, startMins] = block.start_time.split(':').map(Number);
const [endHours, endMins] = block.end_time.split(':').map(Number);
const startMinutes = (startHours - startHour) * 60 + startMins;
const endMinutes = (endHours - startHour) * 60 + endMins;
left = dayIndex * dayWidth + startMinutes * pixelsPerMinute * zoomLevel;
width = (endMinutes - startMinutes) * pixelsPerMinute * zoomLevel;
} else {
// Default to full day if no times specified
left = dayIndex * dayWidth;
width = dayWidth;
}
overlays.push({
block,
left,
width,
dayIndex,
});
}
});
});
return overlays;
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
const baseStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
height: '100%',
pointerEvents: 'auto',
cursor: 'default',
};
if (isBusinessLevel) {
// Business blocks: Red (hard) / Amber (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
background: `repeating-linear-gradient(
-45deg,
rgba(239, 68, 68, 0.3),
rgba(239, 68, 68, 0.3) 5px,
rgba(239, 68, 68, 0.5) 5px,
rgba(239, 68, 68, 0.5) 10px
)`,
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
};
} else {
return {
...baseStyle,
background: 'rgba(251, 191, 36, 0.2)',
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
};
}
} else {
// Resource blocks: Purple (hard) / Cyan (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
background: `repeating-linear-gradient(
-45deg,
rgba(147, 51, 234, 0.25),
rgba(147, 51, 234, 0.25) 5px,
rgba(147, 51, 234, 0.4) 5px,
rgba(147, 51, 234, 0.4) 10px
)`,
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
};
} else {
return {
...baseStyle,
background: 'rgba(6, 182, 212, 0.15)',
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
};
}
}
};
const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => {
setHoveredBlock({
block,
position: { x: e.clientX, y: e.clientY },
});
};
const handleMouseMove = (e: React.MouseEvent) => {
if (hoveredBlock) {
setHoveredBlock({
...hoveredBlock,
position: { x: e.clientX, y: e.clientY },
});
}
};
const handleMouseLeave = () => {
setHoveredBlock(null);
};
return (
<>
{blockOverlays.map((overlay, index) => {
const isBusinessLevel = overlay.block.resource_id === null;
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
return (
<div
key={`${overlay.block.time_block_id}-${overlay.dayIndex}-${index}`}
style={{
...style,
left: overlay.left,
width: overlay.width,
cursor: onDayClick ? 'pointer' : 'default',
}}
onMouseEnter={(e) => handleMouseEnter(e, overlay.block)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={() => onDayClick?.(days[overlay.dayIndex])}
>
{/* Block level indicator */}
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
isBusinessLevel
? 'bg-red-600'
: 'bg-purple-600'
}`}>
{isBusinessLevel ? 'B' : 'R'}
</div>
</div>
);
})}
{/* Tooltip */}
{hoveredBlock && (
<TimeBlockTooltip block={hoveredBlock.block} position={hoveredBlock.position} />
)}
</>
);
};
export default TimeBlockCalendarOverlay;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
/**
* YearlyBlockCalendar - Shows 12-month calendar grid with blocked dates
*
* Displays:
* - Red cells for hard blocks
* - Yellow cells for soft blocks
* - "B" badge for business-level blocks
* - Click to view/edit block
* - Year selector
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
import { BlockedDate, TimeBlockListItem } from '../../types';
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
interface YearlyBlockCalendarProps {
resourceId?: string;
onBlockClick?: (blockId: string) => void;
compact?: boolean;
}
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
resourceId,
onBlockClick,
compact = false,
}) => {
const { t } = useTranslation();
const [year, setYear] = useState(new Date().getFullYear());
const [selectedBlock, setSelectedBlock] = useState<BlockedDate | null>(null);
// Fetch blocked dates for the entire year
const blockedDatesParams = useMemo(() => ({
start_date: `${year}-01-01`,
end_date: `${year + 1}-01-01`,
resource_id: resourceId,
include_business: true,
}), [year, resourceId]);
const { data: blockedDates = [], isLoading } = useBlockedDates(blockedDatesParams);
// Build a map of date -> blocked dates for quick lookup
const blockedDateMap = useMemo(() => {
const map = new Map<string, BlockedDate[]>();
blockedDates.forEach(block => {
const dateKey = block.date;
if (!map.has(dateKey)) {
map.set(dateKey, []);
}
map.get(dateKey)!.push(block);
});
return map;
}, [blockedDates]);
const getDaysInMonth = (month: number): Date[] => {
const days: Date[] = [];
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Add empty cells for days before the first of the month
const startPadding = firstDay.getDay();
for (let i = 0; i < startPadding; i++) {
days.push(null as any);
}
// Add each day of the month
for (let day = 1; day <= lastDay.getDate(); day++) {
days.push(new Date(year, month, day));
}
return days;
};
const getBlockStyle = (blocks: BlockedDate[]): string => {
// Check if any block is a hard block
const hasHardBlock = blocks.some(b => b.block_type === 'HARD');
const hasBusinessBlock = blocks.some(b => b.resource_id === null);
if (hasHardBlock) {
return hasBusinessBlock
? 'bg-red-500 text-white font-bold'
: 'bg-red-400 text-white';
}
return hasBusinessBlock
? 'bg-yellow-400 text-yellow-900 font-bold'
: 'bg-yellow-300 text-yellow-900';
};
const handleDayClick = (day: Date, blocks: BlockedDate[]) => {
if (blocks.length === 0) return;
if (blocks.length === 1 && onBlockClick) {
onBlockClick(blocks[0].time_block_id);
} else {
// Show the first block in the popup, could be enhanced to show all
setSelectedBlock(blocks[0]);
}
};
const renderMonth = (month: number) => {
const days = getDaysInMonth(month);
return (
<div key={month} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{MONTHS[month]}
</h3>
</div>
<div className="p-2">
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-0.5 mb-1">
{WEEKDAYS.map((day, i) => (
<div
key={i}
className="text-center text-[10px] font-medium text-gray-500 dark:text-gray-400"
>
{day}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7 gap-0.5">
{days.map((day, i) => {
if (!day) {
return <div key={`empty-${i}`} className="aspect-square" />;
}
const dateKey = day.toISOString().split('T')[0];
const blocks = blockedDateMap.get(dateKey) || [];
const hasBlocks = blocks.length > 0;
const isToday = new Date().toDateString() === day.toDateString();
return (
<button
key={dateKey}
onClick={() => handleDayClick(day, blocks)}
disabled={!hasBlocks}
className={`
aspect-square flex items-center justify-center text-[11px] rounded
${hasBlocks
? `${getBlockStyle(blocks)} cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-400 dark:hover:ring-gray-500`
: 'text-gray-600 dark:text-gray-400'
}
${isToday && !hasBlocks ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
${!hasBlocks ? 'cursor-default' : ''}
transition-all
`}
title={blocks.map(b => b.title).join(', ') || undefined}
>
{day.getDate()}
{hasBlocks && blocks.some(b => b.resource_id === null) && (
<span className="absolute text-[8px] font-bold top-0 right-0">B</span>
)}
</button>
);
})}
</div>
</div>
</div>
);
};
return (
<div className={compact ? '' : 'p-4'}>
{/* Header with year navigation */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('timeBlocks.yearlyCalendar', 'Yearly Calendar')}
</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setYear(y => y - 1)}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft size={20} />
</button>
<span className="text-lg font-bold text-gray-900 dark:text-white min-w-[60px] text-center">
{year}
</span>
<button
onClick={() => setYear(y => y + 1)}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronRight size={20} />
</button>
<button
onClick={() => setYear(new Date().getFullYear())}
className="ml-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.today', 'Today')}
</button>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-gray-600 dark:text-gray-400">
{t('timeBlocks.hardBlock', 'Hard Block')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-400 rounded" />
<span className="text-gray-600 dark:text-gray-400">
{t('timeBlocks.softBlock', 'Soft Block')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center text-[8px] font-bold text-gray-600 dark:text-gray-300">
B
</div>
<span className="text-gray-600 dark:text-gray-400">
{t('timeBlocks.businessLevel', 'Business Level')}
</span>
</div>
</div>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white" />
</div>
)}
{/* Calendar grid */}
{!isLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 12 }, (_, i) => renderMonth(i))}
</div>
)}
{/* Block detail popup */}
{selectedBlock && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setSelectedBlock(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-4 max-w-sm w-full" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedBlock.title}
</h3>
<button
onClick={() => setSelectedBlock(null)}
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<X size={18} />
</button>
</div>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
<p>
<span className="font-medium">{t('timeBlocks.type', 'Type')}:</span>{' '}
{selectedBlock.block_type === 'HARD' ? t('timeBlocks.hardBlock', 'Hard Block') : t('timeBlocks.softBlock', 'Soft Block')}
</p>
<p>
<span className="font-medium">{t('common.date', 'Date')}:</span>{' '}
{new Date(selectedBlock.date).toLocaleDateString()}
</p>
{!selectedBlock.all_day && (
<p>
<span className="font-medium">{t('common.time', 'Time')}:</span>{' '}
{selectedBlock.start_time} - {selectedBlock.end_time}
</p>
)}
<p>
<span className="font-medium">{t('timeBlocks.level', 'Level')}:</span>{' '}
{selectedBlock.resource_id === null ? t('timeBlocks.businessLevel', 'Business Level') : t('timeBlocks.resourceLevel', 'Resource Level')}
</p>
</div>
{onBlockClick && (
<button
onClick={() => {
onBlockClick(selectedBlock.time_block_id);
setSelectedBlock(null);
}}
className="mt-4 w-full px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
{t('common.viewDetails', 'View Details')}
</button>
)}
</div>
</div>
)}
</div>
);
};
export default YearlyBlockCalendar;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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