feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday) - Implement business-level and resource-level blocking with hard/soft block types - Add multi-select holiday picker for bulk holiday blocking - Add calendar overlay visualization with distinct colors: - Business blocks: Red (hard) / Yellow (soft) - Resource blocks: Purple (hard) / Cyan (soft) - Add month view resource indicators showing 1/n width per resource - Add yearly calendar view for block overview - Add My Availability page for staff self-service - Add contracts module with templates, signing flow, and PDF generation - Update scheduler with click-to-day navigation in week view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,8 @@ const Payments = React.lazy(() => import('./pages/Payments'));
|
||||
const Resources = React.lazy(() => import('./pages/Resources'));
|
||||
const Services = React.lazy(() => import('./pages/Services'));
|
||||
const Staff = React.lazy(() => import('./pages/Staff'));
|
||||
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
|
||||
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
|
||||
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
|
||||
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
||||
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
||||
@@ -78,8 +80,10 @@ const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
|
||||
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
|
||||
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
|
||||
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
|
||||
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
|
||||
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
||||
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
|
||||
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
|
||||
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
|
||||
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
||||
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
||||
@@ -98,6 +102,9 @@ const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Pl
|
||||
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
||||
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
|
||||
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
|
||||
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
|
||||
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
||||
|
||||
// Settings pages
|
||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||
@@ -307,6 +314,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -340,6 +348,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -367,6 +376,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -671,8 +681,10 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/help/services" element={<HelpServices />} />
|
||||
<Route path="/help/resources" element={<HelpResources />} />
|
||||
<Route path="/help/staff" element={<HelpStaff />} />
|
||||
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
|
||||
<Route path="/help/messages" element={<HelpMessages />} />
|
||||
<Route path="/help/payments" element={<HelpPayments />} />
|
||||
<Route path="/help/contracts" element={<HelpContracts />} />
|
||||
<Route path="/help/plugins" element={<HelpPlugins />} />
|
||||
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
|
||||
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
||||
@@ -775,6 +787,46 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/time-blocks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<TimeBlocks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-availability"
|
||||
element={
|
||||
hasAccess(['staff', 'resource']) ? (
|
||||
<MyAvailability user={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Contracts />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<ContractTemplates />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={
|
||||
|
||||
@@ -20,9 +20,13 @@ const routeToHelpPath: Record<string, string> = {
|
||||
'/services': '/help/services',
|
||||
'/resources': '/help/resources',
|
||||
'/staff': '/help/staff',
|
||||
'/time-blocks': '/help/time-blocks',
|
||||
'/my-availability': '/help/time-blocks',
|
||||
'/messages': '/help/messages',
|
||||
'/tickets': '/help/ticketing',
|
||||
'/payments': '/help/payments',
|
||||
'/contracts': '/help/contracts',
|
||||
'/contracts/templates': '/help/contracts',
|
||||
'/plugins': '/help/plugins',
|
||||
'/plugins/marketplace': '/help/plugins',
|
||||
'/plugins/my-plugins': '/help/plugins',
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
HelpCircle,
|
||||
Clock,
|
||||
Plug,
|
||||
FileSignature,
|
||||
CalendarOff,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
@@ -121,6 +123,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
/>
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
<SidebarItem
|
||||
to="/my-availability"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.myAvailability', 'My Availability')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Manage Section - Staff+ */}
|
||||
@@ -145,12 +155,26 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal file
247
frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* TimeBlockCalendarOverlay - Renders time block overlays on the scheduler calendar
|
||||
*
|
||||
* Shows blocked time periods with visual styling:
|
||||
* - Hard blocks: Red with diagonal stripes, 50% opacity
|
||||
* - Soft blocks: Yellow with dotted border, 30% opacity
|
||||
* - Business blocks: Full-width across the lane
|
||||
* - Resource blocks: Only on matching resource lane
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { BlockedDate, BlockType } from '../../types';
|
||||
|
||||
interface TimeBlockCalendarOverlayProps {
|
||||
blockedDates: BlockedDate[];
|
||||
resourceId: string;
|
||||
viewDate: Date;
|
||||
zoomLevel: number;
|
||||
pixelsPerMinute: number;
|
||||
startHour: number;
|
||||
dayWidth: number;
|
||||
laneHeight: number;
|
||||
days: Date[];
|
||||
onDayClick?: (day: Date) => void;
|
||||
}
|
||||
|
||||
interface TimeBlockTooltipProps {
|
||||
block: BlockedDate;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
const TimeBlockTooltip: React.FC<TimeBlockTooltipProps> = ({ block, position }) => {
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[100] px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg max-w-xs pointer-events-none"
|
||||
style={{
|
||||
left: position.x + 10,
|
||||
top: position.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{block.title}</div>
|
||||
<div className="text-xs text-gray-300 mt-1">
|
||||
{block.block_type === 'HARD' ? 'Hard Block' : 'Soft Block'}
|
||||
{block.all_day ? ' (All Day)' : ` (${block.start_time} - ${block.end_time})`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
blockedDates,
|
||||
resourceId,
|
||||
viewDate,
|
||||
zoomLevel,
|
||||
pixelsPerMinute,
|
||||
startHour,
|
||||
dayWidth,
|
||||
laneHeight,
|
||||
days,
|
||||
onDayClick,
|
||||
}) => {
|
||||
const [hoveredBlock, setHoveredBlock] = useState<{ block: BlockedDate; position: { x: number; y: number } } | null>(null);
|
||||
|
||||
// Filter blocks for this resource (includes business-level blocks where resource_id is null)
|
||||
const relevantBlocks = useMemo(() => {
|
||||
return blockedDates.filter(
|
||||
(block) => block.resource_id === null || block.resource_id === resourceId
|
||||
);
|
||||
}, [blockedDates, resourceId]);
|
||||
|
||||
// Calculate block positions for each day
|
||||
const blockOverlays = useMemo(() => {
|
||||
const overlays: Array<{
|
||||
block: BlockedDate;
|
||||
left: number;
|
||||
width: number;
|
||||
dayIndex: number;
|
||||
}> = [];
|
||||
|
||||
relevantBlocks.forEach((block) => {
|
||||
// Parse date string as local date, not UTC
|
||||
// "2025-12-06" should be Dec 6 in local timezone, not UTC
|
||||
const [year, month, dayNum] = block.date.split('-').map(Number);
|
||||
const blockDate = new Date(year, month - 1, dayNum);
|
||||
blockDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Find which day this block falls on
|
||||
days.forEach((day, dayIndex) => {
|
||||
const dayStart = new Date(day);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
if (blockDate.getTime() === dayStart.getTime()) {
|
||||
let left: number;
|
||||
let width: number;
|
||||
|
||||
if (block.all_day) {
|
||||
// Full day block
|
||||
left = dayIndex * dayWidth;
|
||||
width = dayWidth;
|
||||
} else if (block.start_time && block.end_time) {
|
||||
// Partial day block
|
||||
const [startHours, startMins] = block.start_time.split(':').map(Number);
|
||||
const [endHours, endMins] = block.end_time.split(':').map(Number);
|
||||
|
||||
const startMinutes = (startHours - startHour) * 60 + startMins;
|
||||
const endMinutes = (endHours - startHour) * 60 + endMins;
|
||||
|
||||
left = dayIndex * dayWidth + startMinutes * pixelsPerMinute * zoomLevel;
|
||||
width = (endMinutes - startMinutes) * pixelsPerMinute * zoomLevel;
|
||||
} else {
|
||||
// Default to full day if no times specified
|
||||
left = dayIndex * dayWidth;
|
||||
width = dayWidth;
|
||||
}
|
||||
|
||||
overlays.push({
|
||||
block,
|
||||
left,
|
||||
width,
|
||||
dayIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return overlays;
|
||||
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
||||
|
||||
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
if (isBusinessLevel) {
|
||||
// Business blocks: Red (hard) / Amber (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(239, 68, 68, 0.3) 5px,
|
||||
rgba(239, 68, 68, 0.5) 5px,
|
||||
rgba(239, 68, 68, 0.5) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(251, 191, 36, 0.2)',
|
||||
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Resource blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => {
|
||||
setHoveredBlock({
|
||||
block,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (hoveredBlock) {
|
||||
setHoveredBlock({
|
||||
...hoveredBlock,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredBlock(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockOverlays.map((overlay, index) => {
|
||||
const isBusinessLevel = overlay.block.resource_id === null;
|
||||
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${overlay.block.time_block_id}-${overlay.dayIndex}-${index}`}
|
||||
style={{
|
||||
...style,
|
||||
left: overlay.left,
|
||||
width: overlay.width,
|
||||
cursor: onDayClick ? 'pointer' : 'default',
|
||||
}}
|
||||
onMouseEnter={(e) => handleMouseEnter(e, overlay.block)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
||||
>
|
||||
{/* Block level indicator */}
|
||||
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
|
||||
isBusinessLevel
|
||||
? 'bg-red-600'
|
||||
: 'bg-purple-600'
|
||||
}`}>
|
||||
{isBusinessLevel ? 'B' : 'R'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredBlock && (
|
||||
<TimeBlockTooltip block={hoveredBlock.block} position={hoveredBlock.position} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBlockCalendarOverlay;
|
||||
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
1263
frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal file
298
frontend/src/components/time-blocks/YearlyBlockCalendar.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* YearlyBlockCalendar - Shows 12-month calendar grid with blocked dates
|
||||
*
|
||||
* Displays:
|
||||
* - Red cells for hard blocks
|
||||
* - Yellow cells for soft blocks
|
||||
* - "B" badge for business-level blocks
|
||||
* - Click to view/edit block
|
||||
* - Year selector
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
|
||||
import { BlockedDate, TimeBlockListItem } from '../../types';
|
||||
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
|
||||
|
||||
interface YearlyBlockCalendarProps {
|
||||
resourceId?: string;
|
||||
onBlockClick?: (blockId: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
|
||||
resourceId,
|
||||
onBlockClick,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [selectedBlock, setSelectedBlock] = useState<BlockedDate | null>(null);
|
||||
|
||||
// Fetch blocked dates for the entire year
|
||||
const blockedDatesParams = useMemo(() => ({
|
||||
start_date: `${year}-01-01`,
|
||||
end_date: `${year + 1}-01-01`,
|
||||
resource_id: resourceId,
|
||||
include_business: true,
|
||||
}), [year, resourceId]);
|
||||
|
||||
const { data: blockedDates = [], isLoading } = useBlockedDates(blockedDatesParams);
|
||||
|
||||
// Build a map of date -> blocked dates for quick lookup
|
||||
const blockedDateMap = useMemo(() => {
|
||||
const map = new Map<string, BlockedDate[]>();
|
||||
blockedDates.forEach(block => {
|
||||
const dateKey = block.date;
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, []);
|
||||
}
|
||||
map.get(dateKey)!.push(block);
|
||||
});
|
||||
return map;
|
||||
}, [blockedDates]);
|
||||
|
||||
const getDaysInMonth = (month: number): Date[] => {
|
||||
const days: Date[] = [];
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Add empty cells for days before the first of the month
|
||||
const startPadding = firstDay.getDay();
|
||||
for (let i = 0; i < startPadding; i++) {
|
||||
days.push(null as any);
|
||||
}
|
||||
|
||||
// Add each day of the month
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
days.push(new Date(year, month, day));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const getBlockStyle = (blocks: BlockedDate[]): string => {
|
||||
// Check if any block is a hard block
|
||||
const hasHardBlock = blocks.some(b => b.block_type === 'HARD');
|
||||
const hasBusinessBlock = blocks.some(b => b.resource_id === null);
|
||||
|
||||
if (hasHardBlock) {
|
||||
return hasBusinessBlock
|
||||
? 'bg-red-500 text-white font-bold'
|
||||
: 'bg-red-400 text-white';
|
||||
}
|
||||
return hasBusinessBlock
|
||||
? 'bg-yellow-400 text-yellow-900 font-bold'
|
||||
: 'bg-yellow-300 text-yellow-900';
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date, blocks: BlockedDate[]) => {
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
if (blocks.length === 1 && onBlockClick) {
|
||||
onBlockClick(blocks[0].time_block_id);
|
||||
} else {
|
||||
// Show the first block in the popup, could be enhanced to show all
|
||||
setSelectedBlock(blocks[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMonth = (month: number) => {
|
||||
const days = getDaysInMonth(month);
|
||||
|
||||
return (
|
||||
<div key={month} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{MONTHS[month]}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1">
|
||||
{WEEKDAYS.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-center text-[10px] font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Days grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{days.map((day, i) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${i}`} className="aspect-square" />;
|
||||
}
|
||||
|
||||
const dateKey = day.toISOString().split('T')[0];
|
||||
const blocks = blockedDateMap.get(dateKey) || [];
|
||||
const hasBlocks = blocks.length > 0;
|
||||
const isToday = new Date().toDateString() === day.toDateString();
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
onClick={() => handleDayClick(day, blocks)}
|
||||
disabled={!hasBlocks}
|
||||
className={`
|
||||
aspect-square flex items-center justify-center text-[11px] rounded
|
||||
${hasBlocks
|
||||
? `${getBlockStyle(blocks)} cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-400 dark:hover:ring-gray-500`
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
${isToday && !hasBlocks ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
||||
${!hasBlocks ? 'cursor-default' : ''}
|
||||
transition-all
|
||||
`}
|
||||
title={blocks.map(b => b.title).join(', ') || undefined}
|
||||
>
|
||||
{day.getDate()}
|
||||
{hasBlocks && blocks.some(b => b.resource_id === null) && (
|
||||
<span className="absolute text-[8px] font-bold top-0 right-0">B</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'p-4'}>
|
||||
{/* Header with year navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.yearlyCalendar', 'Yearly Calendar')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setYear(y => y - 1)}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white min-w-[60px] text-center">
|
||||
{year}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setYear(y => y + 1)}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setYear(new Date().getFullYear())}
|
||||
className="ml-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.today', 'Today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.hardBlock', 'Hard Block')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-yellow-400 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.softBlock', 'Soft Block')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center text-[8px] font-bold text-gray-600 dark:text-gray-300">
|
||||
B
|
||||
</div>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('timeBlocks.businessLevel', 'Business Level')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar grid */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 12 }, (_, i) => renderMonth(i))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block detail popup */}
|
||||
{selectedBlock && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setSelectedBlock(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-4 max-w-sm w-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedBlock.title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedBlock(null)}
|
||||
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
<span className="font-medium">{t('timeBlocks.type', 'Type')}:</span>{' '}
|
||||
{selectedBlock.block_type === 'HARD' ? t('timeBlocks.hardBlock', 'Hard Block') : t('timeBlocks.softBlock', 'Soft Block')}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">{t('common.date', 'Date')}:</span>{' '}
|
||||
{new Date(selectedBlock.date).toLocaleDateString()}
|
||||
</p>
|
||||
{!selectedBlock.all_day && (
|
||||
<p>
|
||||
<span className="font-medium">{t('common.time', 'Time')}:</span>{' '}
|
||||
{selectedBlock.start_time} - {selectedBlock.end_time}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">{t('timeBlocks.level', 'Level')}:</span>{' '}
|
||||
{selectedBlock.resource_id === null ? t('timeBlocks.businessLevel', 'Business Level') : t('timeBlocks.resourceLevel', 'Resource Level')}
|
||||
</p>
|
||||
</div>
|
||||
{onBlockClick && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onBlockClick(selectedBlock.time_block_id);
|
||||
setSelectedBlock(null);
|
||||
}}
|
||||
className="mt-4 w-full px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('common.viewDetails', 'View Details')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YearlyBlockCalendar;
|
||||
388
frontend/src/hooks/useContracts.ts
Normal file
388
frontend/src/hooks/useContracts.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Contract Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
ContractTemplate,
|
||||
Contract,
|
||||
ContractPublicView,
|
||||
ContractScope,
|
||||
ContractTemplateStatus,
|
||||
} from '../types';
|
||||
|
||||
// --- Contract Templates ---
|
||||
|
||||
/**
|
||||
* Hook to fetch all contract templates for current business
|
||||
*/
|
||||
export const useContractTemplates = (status?: ContractTemplateStatus) => {
|
||||
return useQuery<ContractTemplate[]>({
|
||||
queryKey: ['contract-templates', status],
|
||||
queryFn: async () => {
|
||||
const params = status ? { status } : {};
|
||||
const { data } = await apiClient.get('/contracts/templates/', { params });
|
||||
|
||||
return data.map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
content: t.content,
|
||||
scope: t.scope as ContractScope,
|
||||
status: t.status as ContractTemplateStatus,
|
||||
expires_after_days: t.expires_after_days,
|
||||
version: t.version,
|
||||
version_notes: t.version_notes || '',
|
||||
services: t.services || [],
|
||||
created_by: t.created_by ? String(t.created_by) : null,
|
||||
created_by_name: t.created_by_name || null,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single contract template
|
||||
*/
|
||||
export const useContractTemplate = (id: string) => {
|
||||
return useQuery<ContractTemplate>({
|
||||
queryKey: ['contract-templates', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/contracts/templates/${id}/`);
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
content: data.content,
|
||||
scope: data.scope as ContractScope,
|
||||
status: data.status as ContractTemplateStatus,
|
||||
expires_after_days: data.expires_after_days,
|
||||
version: data.version,
|
||||
version_notes: data.version_notes || '',
|
||||
services: data.services || [],
|
||||
created_by: data.created_by ? String(data.created_by) : null,
|
||||
created_by_name: data.created_by_name || null,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
interface ContractTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
scope: ContractScope;
|
||||
status?: ContractTemplateStatus;
|
||||
expires_after_days?: number | null;
|
||||
version_notes?: string;
|
||||
services?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a contract template
|
||||
*/
|
||||
export const useCreateContractTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (templateData: ContractTemplateInput) => {
|
||||
const { data } = await apiClient.post('/contracts/templates/', templateData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a contract template
|
||||
*/
|
||||
export const useUpdateContractTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
updates,
|
||||
}: {
|
||||
id: string;
|
||||
updates: Partial<ContractTemplateInput>;
|
||||
}) => {
|
||||
const { data } = await apiClient.patch(`/contracts/templates/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a contract template
|
||||
*/
|
||||
export const useDeleteContractTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/contracts/templates/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to duplicate a contract template
|
||||
*/
|
||||
export const useDuplicateContractTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/contracts/templates/${id}/duplicate/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-templates'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to preview a contract template
|
||||
*/
|
||||
export const usePreviewContractTemplate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
context,
|
||||
}: {
|
||||
id: string;
|
||||
context?: Record<string, any>;
|
||||
}) => {
|
||||
const { data } = await apiClient.post(
|
||||
`/contracts/templates/${id}/preview/`,
|
||||
context || {}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// --- Contracts ---
|
||||
|
||||
/**
|
||||
* Hook to fetch all contracts for current business
|
||||
*/
|
||||
export const useContracts = (filters?: {
|
||||
status?: string;
|
||||
customer?: string;
|
||||
appointment?: string;
|
||||
}) => {
|
||||
return useQuery<Contract[]>({
|
||||
queryKey: ['contracts', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/contracts/', {
|
||||
params: filters,
|
||||
});
|
||||
|
||||
return data.map((c: any) => ({
|
||||
id: String(c.id),
|
||||
template: String(c.template),
|
||||
template_name: c.template_name,
|
||||
template_version: c.template_version,
|
||||
scope: c.scope as ContractScope,
|
||||
status: c.status,
|
||||
content: c.content,
|
||||
customer: c.customer ? String(c.customer) : undefined,
|
||||
customer_name: c.customer_name || undefined,
|
||||
customer_email: c.customer_email || undefined,
|
||||
appointment: c.appointment ? String(c.appointment) : undefined,
|
||||
appointment_service_name: c.appointment_service_name || undefined,
|
||||
appointment_start_time: c.appointment_start_time || undefined,
|
||||
service: c.service ? String(c.service) : undefined,
|
||||
service_name: c.service_name || undefined,
|
||||
sent_at: c.sent_at,
|
||||
signed_at: c.signed_at,
|
||||
expires_at: c.expires_at,
|
||||
voided_at: c.voided_at,
|
||||
voided_reason: c.voided_reason,
|
||||
public_token: c.public_token,
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single contract
|
||||
*/
|
||||
export const useContract = (id: string) => {
|
||||
return useQuery<Contract>({
|
||||
queryKey: ['contracts', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/contracts/${id}/`);
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
template: String(data.template),
|
||||
template_name: data.template_name,
|
||||
template_version: data.template_version,
|
||||
scope: data.scope as ContractScope,
|
||||
status: data.status,
|
||||
content: data.content,
|
||||
customer: data.customer ? String(data.customer) : undefined,
|
||||
customer_name: data.customer_name || undefined,
|
||||
customer_email: data.customer_email || undefined,
|
||||
appointment: data.appointment ? String(data.appointment) : undefined,
|
||||
appointment_service_name: data.appointment_service_name || undefined,
|
||||
appointment_start_time: data.appointment_start_time || undefined,
|
||||
service: data.service ? String(data.service) : undefined,
|
||||
service_name: data.service_name || undefined,
|
||||
sent_at: data.sent_at,
|
||||
signed_at: data.signed_at,
|
||||
expires_at: data.expires_at,
|
||||
voided_at: data.voided_at,
|
||||
voided_reason: data.voided_reason,
|
||||
public_token: data.public_token,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
interface ContractInput {
|
||||
template: string;
|
||||
customer?: string;
|
||||
appointment?: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a contract
|
||||
*/
|
||||
export const useCreateContract = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (contractData: ContractInput) => {
|
||||
const { data } = await apiClient.post('/contracts/', contractData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to send a contract to customer
|
||||
*/
|
||||
export const useSendContract = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/contracts/${id}/send/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to void a contract
|
||||
*/
|
||||
export const useVoidContract = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, reason }: { id: string; reason: string }) => {
|
||||
const { data } = await apiClient.post(`/contracts/${id}/void/`, { reason });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to resend a contract
|
||||
*/
|
||||
export const useResendContract = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/contracts/${id}/resend/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// --- Public Contract Access (no auth required) ---
|
||||
|
||||
/**
|
||||
* Hook to fetch public contract view by token (no auth required)
|
||||
*/
|
||||
export const usePublicContract = (token: string) => {
|
||||
return useQuery<ContractPublicView>({
|
||||
queryKey: ['public-contracts', token],
|
||||
queryFn: async () => {
|
||||
// Use a plain axios instance without auth
|
||||
const { data } = await apiClient.get(`/contracts/public/${token}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to sign a contract (no auth required)
|
||||
*/
|
||||
export const useSignContract = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
token,
|
||||
signature_data,
|
||||
signer_name,
|
||||
signer_email,
|
||||
}: {
|
||||
token: string;
|
||||
signature_data: string;
|
||||
signer_name: string;
|
||||
signer_email: string;
|
||||
}) => {
|
||||
const { data } = await apiClient.post(`/contracts/public/${token}/sign/`, {
|
||||
signature_data,
|
||||
signer_name,
|
||||
signer_email,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
316
frontend/src/hooks/useTimeBlocks.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Time Block Management Hooks
|
||||
*
|
||||
* Provides hooks for managing time blocks and holidays.
|
||||
* Time blocks allow businesses to block off time for closures, holidays,
|
||||
* resource unavailability, and recurring patterns.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
TimeBlock,
|
||||
TimeBlockListItem,
|
||||
BlockedDate,
|
||||
Holiday,
|
||||
TimeBlockConflictCheck,
|
||||
MyBlocksResponse,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
} from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
// =============================================================================
|
||||
|
||||
export interface TimeBlockFilters {
|
||||
level?: 'business' | 'resource';
|
||||
resource_id?: string;
|
||||
block_type?: BlockType;
|
||||
recurrence_type?: RecurrenceType;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface BlockedDatesParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
resource_id?: string;
|
||||
include_business?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimeBlockData {
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string | null;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
recurrence_start?: string;
|
||||
recurrence_end?: string;
|
||||
}
|
||||
|
||||
export interface CheckConflictsData {
|
||||
recurrence_type: RecurrenceType;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
resource_id?: string | null;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Time Block Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch time blocks with optional filters
|
||||
*/
|
||||
export const useTimeBlocks = (filters?: TimeBlockFilters) => {
|
||||
return useQuery<TimeBlockListItem[]>({
|
||||
queryKey: ['time-blocks', filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.level) params.append('level', filters.level);
|
||||
if (filters?.resource_id) params.append('resource_id', filters.resource_id);
|
||||
if (filters?.block_type) params.append('block_type', filters.block_type);
|
||||
if (filters?.recurrence_type) params.append('recurrence_type', filters.recurrence_type);
|
||||
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/?${params}`);
|
||||
return data.map((block: any) => ({
|
||||
...block,
|
||||
id: String(block.id),
|
||||
resource: block.resource ? String(block.resource) : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single time block
|
||||
*/
|
||||
export const useTimeBlock = (id: string) => {
|
||||
return useQuery<TimeBlock>({
|
||||
queryKey: ['time-blocks', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/time-blocks/${id}/`);
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id),
|
||||
resource: data.resource ? String(data.resource) : null,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get blocked dates for calendar visualization
|
||||
*/
|
||||
export const useBlockedDates = (params: BlockedDatesParams) => {
|
||||
return useQuery<BlockedDate[]>({
|
||||
queryKey: ['blocked-dates', params],
|
||||
queryFn: async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
});
|
||||
if (params.resource_id) queryParams.append('resource_id', params.resource_id);
|
||||
if (params.include_business !== undefined) {
|
||||
queryParams.append('include_business', String(params.include_business));
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get(`/time-blocks/blocked_dates/?${queryParams}`);
|
||||
return data.blocked_dates.map((block: any) => ({
|
||||
...block,
|
||||
resource_id: block.resource_id ? String(block.resource_id) : null,
|
||||
time_block_id: String(block.time_block_id),
|
||||
}));
|
||||
},
|
||||
enabled: !!params.start_date && !!params.end_date,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get time blocks for the current staff member
|
||||
*/
|
||||
export const useMyBlocks = () => {
|
||||
return useQuery<MyBlocksResponse>({
|
||||
queryKey: ['my-blocks'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/time-blocks/my_blocks/');
|
||||
return {
|
||||
business_blocks: data.business_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
my_blocks: data.my_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
resource_id: String(data.resource_id),
|
||||
resource_name: data.resource_name,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a time block
|
||||
*/
|
||||
export const useCreateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (blockData: CreateTimeBlockData) => {
|
||||
const payload = {
|
||||
...blockData,
|
||||
resource: blockData.resource ? parseInt(blockData.resource) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/', payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a time block
|
||||
*/
|
||||
export const useUpdateTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<CreateTimeBlockData> }) => {
|
||||
const payload: any = { ...updates };
|
||||
if (updates.resource !== undefined) {
|
||||
payload.resource = updates.resource ? parseInt(updates.resource) : null;
|
||||
}
|
||||
const { data } = await apiClient.patch(`/time-blocks/${id}/`, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a time block
|
||||
*/
|
||||
export const useDeleteTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/time-blocks/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle a time block's active status
|
||||
*/
|
||||
export const useToggleTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/toggle/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for conflicts before creating a time block
|
||||
*/
|
||||
export const useCheckConflicts = () => {
|
||||
return useMutation<TimeBlockConflictCheck, Error, CheckConflictsData>({
|
||||
mutationFn: async (checkData) => {
|
||||
const payload = {
|
||||
...checkData,
|
||||
resource_id: checkData.resource_id ? parseInt(checkData.resource_id) : null,
|
||||
};
|
||||
const { data } = await apiClient.post('/time-blocks/check_conflicts/', payload);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Holiday Hooks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch holidays
|
||||
*/
|
||||
export const useHolidays = (country?: string) => {
|
||||
return useQuery<Holiday[]>({
|
||||
queryKey: ['holidays', country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single holiday by code
|
||||
*/
|
||||
export const useHoliday = (code: string) => {
|
||||
return useQuery<Holiday>({
|
||||
queryKey: ['holidays', code],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/holidays/${code}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!code,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get holiday dates for a specific year
|
||||
*/
|
||||
export const useHolidayDates = (year?: number, country?: string) => {
|
||||
return useQuery<{ year: number; holidays: { code: string; name: string; date: string }[] }>({
|
||||
queryKey: ['holiday-dates', year, country],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.append('year', String(year));
|
||||
if (country) params.append('country', country);
|
||||
|
||||
const { data } = await apiClient.get(`/holidays/dates/?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -92,6 +92,7 @@
|
||||
"platformSettings": "Platform Settings",
|
||||
"tickets": "Tickets",
|
||||
"help": "Help",
|
||||
"contracts": "Contracts",
|
||||
"platformGuide": "Platform Guide",
|
||||
"ticketingHelp": "Ticketing System",
|
||||
"apiDocs": "API Docs",
|
||||
@@ -409,6 +410,106 @@
|
||||
"sendReply": "Send Reply",
|
||||
"ticketClosedNoReply": "This ticket is closed. If you need further assistance, please open a new support request."
|
||||
},
|
||||
"contracts": {
|
||||
"title": "Contracts",
|
||||
"description": "Manage contract templates and sent contracts",
|
||||
"templates": "Templates",
|
||||
"allContracts": "All Contracts",
|
||||
"createTemplate": "Create Template",
|
||||
"createContract": "Create Contract",
|
||||
"editTemplate": "Edit Template",
|
||||
"viewContract": "View Contract",
|
||||
"noTemplates": "No contract templates yet",
|
||||
"noContracts": "No contracts yet",
|
||||
"templateName": "Template Name",
|
||||
"templateDescription": "Description",
|
||||
"content": "Content",
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"customer": "Customer Agreement",
|
||||
"appointment": "Appointment Agreement"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"draft": "Draft",
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"pending": "Pending Signature",
|
||||
"signed": "Signed",
|
||||
"expired": "Expired",
|
||||
"voided": "Voided"
|
||||
},
|
||||
"expiresAfterDays": "Expires After (Days)",
|
||||
"expiresAfterDaysHint": "Leave empty for no expiration",
|
||||
"versionNotes": "Version Notes",
|
||||
"versionNotesPlaceholder": "What changed in this version?",
|
||||
"services": "Applicable Services",
|
||||
"servicesHint": "Leave empty to apply to all services",
|
||||
"customer": "Customer",
|
||||
"appointment": "Appointment",
|
||||
"service": "Service",
|
||||
"sentAt": "Sent At",
|
||||
"signedAt": "Signed At",
|
||||
"expiresAt": "Expires At",
|
||||
"actions": {
|
||||
"send": "Send Contract",
|
||||
"resend": "Resend Contract",
|
||||
"void": "Void Contract",
|
||||
"duplicate": "Duplicate Template",
|
||||
"preview": "Preview",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"sendContract": {
|
||||
"title": "Send Contract",
|
||||
"selectCustomer": "Select Customer",
|
||||
"selectAppointment": "Select Appointment (Optional)",
|
||||
"selectService": "Select Service (Optional)",
|
||||
"send": "Send",
|
||||
"success": "Contract sent successfully",
|
||||
"error": "Failed to send contract"
|
||||
},
|
||||
"voidContract": {
|
||||
"title": "Void Contract",
|
||||
"reason": "Reason for voiding",
|
||||
"reasonPlaceholder": "Why is this contract being voided?",
|
||||
"confirm": "Void Contract",
|
||||
"success": "Contract voided successfully",
|
||||
"error": "Failed to void contract"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview Contract",
|
||||
"sampleData": "Using sample data for preview"
|
||||
},
|
||||
"signing": {
|
||||
"title": "Sign Contract",
|
||||
"businessName": "{{businessName}}",
|
||||
"contractFor": "Contract for {{customerName}}",
|
||||
"pleaseReview": "Please review and sign this contract",
|
||||
"signerName": "Your Full Name",
|
||||
"signerEmail": "Your Email",
|
||||
"signatureLabel": "Sign Below",
|
||||
"signaturePlaceholder": "Draw your signature here",
|
||||
"clearSignature": "Clear",
|
||||
"agreeToTerms": "I agree to the terms and conditions",
|
||||
"submitSignature": "Submit Signature",
|
||||
"submitting": "Submitting...",
|
||||
"success": "Contract signed successfully!",
|
||||
"error": "Failed to sign contract",
|
||||
"expired": "This contract has expired",
|
||||
"alreadySigned": "This contract has already been signed",
|
||||
"notFound": "Contract not found",
|
||||
"signedBy": "Signed by {{name}} on {{date}}",
|
||||
"thankYou": "Thank you for signing!"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load contracts",
|
||||
"createFailed": "Failed to create contract",
|
||||
"updateFailed": "Failed to update contract",
|
||||
"deleteFailed": "Failed to delete contract",
|
||||
"sendFailed": "Failed to send contract",
|
||||
"voidFailed": "Failed to void contract"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {{name}}!",
|
||||
|
||||
258
frontend/src/pages/ContractSigning.tsx
Normal file
258
frontend/src/pages/ContractSigning.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { FileSignature, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
import { usePublicContract, useSignContract } from '../hooks/useContracts';
|
||||
|
||||
const ContractSigning: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [signerName, setSignerName] = useState('');
|
||||
const [signerEmail, setSignerEmail] = useState('');
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState('');
|
||||
|
||||
const { data: contractData, isLoading, error } = usePublicContract(token || '');
|
||||
const signMutation = useSignContract();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !signatureData || !agreedToTerms) return;
|
||||
|
||||
try {
|
||||
await signMutation.mutateAsync({
|
||||
token,
|
||||
signature_data: signatureData,
|
||||
signer_name: signerName,
|
||||
signer_email: signerEmail,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sign contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !contractData) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.notFound')}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
This contract link is invalid or has expired.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.contract.status === 'SIGNED') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.alreadySigned')}
|
||||
</h2>
|
||||
{contractData.signature && (
|
||||
<p className="text-gray-600">
|
||||
{t('contracts.signing.signedBy', {
|
||||
name: contractData.signature.signer_name,
|
||||
date: new Date(contractData.signature.signed_at).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.is_expired) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.expired')}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
This contract has expired and can no longer be signed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signMutation.isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.success')}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('contracts.signing.thankYou')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{contractData.business.logo_url ? (
|
||||
<img
|
||||
src={contractData.business.logo_url}
|
||||
alt={contractData.business.name}
|
||||
className="h-12 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FileSignature className="w-12 h-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{contractData.business.name}
|
||||
</h1>
|
||||
<p className="text-gray-600">{contractData.template.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{contractData.customer && (
|
||||
<p className="text-gray-600">
|
||||
{t('contracts.signing.contractFor', {
|
||||
customerName: contractData.customer.name,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contract Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature Form */}
|
||||
{contractData.can_sign && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
{t('contracts.signing.title')}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{t('contracts.signing.pleaseReview')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerEmail')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={signerEmail}
|
||||
onChange={(e) => setSignerEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signatureLabel')}
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<textarea
|
||||
placeholder={t('contracts.signing.signaturePlaceholder')}
|
||||
value={signatureData}
|
||||
onChange={(e) => setSignatureData(e.target.value)}
|
||||
className="w-full h-32 p-2 border border-gray-200 rounded resize-none"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSignatureData('')}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{t('contracts.signing.clearSignature')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="agree" className="text-sm text-gray-700">
|
||||
{t('contracts.signing.agreeToTerms')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={signMutation.isPending || !agreedToTerms}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{signMutation.isPending ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
{t('contracts.signing.submitting')}
|
||||
</>
|
||||
) : (
|
||||
t('contracts.signing.submitSignature')
|
||||
)}
|
||||
</button>
|
||||
|
||||
{signMutation.isError && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
{t('contracts.signing.error')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractSigning;
|
||||
527
frontend/src/pages/ContractTemplates.tsx
Normal file
527
frontend/src/pages/ContractTemplates.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FileSignature, Plus, Search, ArrowLeft, Pencil, Trash2, X, Loader2,
|
||||
Eye, Copy, Check, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useContractTemplates,
|
||||
useCreateContractTemplate,
|
||||
useUpdateContractTemplate,
|
||||
useDeleteContractTemplate,
|
||||
} from '../hooks/useContracts';
|
||||
import apiClient from '../api/client';
|
||||
import { ContractTemplate, ContractTemplateStatus, ContractScope } from '../types';
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
scope: ContractScope;
|
||||
status: ContractTemplateStatus;
|
||||
expires_after_days: number | null;
|
||||
}
|
||||
|
||||
const ContractTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<ContractTemplateStatus | 'all'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Auto-open create modal if ?create=true is in URL
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'true') {
|
||||
setIsModalOpen(true);
|
||||
// Remove the param from URL to prevent re-opening on refresh
|
||||
searchParams.delete('create');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const { data: templates, isLoading, error } = useContractTemplates();
|
||||
const createTemplate = useCreateContractTemplate();
|
||||
const updateTemplate = useUpdateContractTemplate();
|
||||
const deleteTemplate = useDeleteContractTemplate();
|
||||
|
||||
const [formData, setFormData] = useState<TemplateFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
scope: 'APPOINTMENT',
|
||||
status: 'DRAFT',
|
||||
expires_after_days: null,
|
||||
});
|
||||
|
||||
// Filter templates by status and search term
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!templates) return [];
|
||||
return templates.filter((template) => {
|
||||
const matchesTab = activeTab === 'all' || template.status === activeTab;
|
||||
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
}, [templates, activeTab, searchTerm]);
|
||||
|
||||
// Count templates by status
|
||||
const statusCounts = useMemo(() => {
|
||||
if (!templates) return { all: 0, ACTIVE: 0, DRAFT: 0, ARCHIVED: 0 };
|
||||
return {
|
||||
all: templates.length,
|
||||
ACTIVE: templates.filter((t) => t.status === 'ACTIVE').length,
|
||||
DRAFT: templates.filter((t) => t.status === 'DRAFT').length,
|
||||
ARCHIVED: templates.filter((t) => t.status === 'ARCHIVED').length,
|
||||
};
|
||||
}, [templates]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
scope: 'APPOINTMENT',
|
||||
status: 'DRAFT',
|
||||
expires_after_days: null,
|
||||
});
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (template: ContractTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setFormData({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
scope: template.scope,
|
||||
status: template.status,
|
||||
expires_after_days: template.expires_after_days,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingTemplate) {
|
||||
await updateTemplate.mutateAsync({
|
||||
id: editingTemplate.id,
|
||||
updates: formData,
|
||||
});
|
||||
} else {
|
||||
await createTemplate.mutateAsync(formData);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteTemplate.mutateAsync(id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ContractTemplateStatus) => {
|
||||
const styles = {
|
||||
ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
DRAFT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
ARCHIVED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||
{t(`contracts.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getScopeBadge = (scope: ContractScope) => {
|
||||
const styles = {
|
||||
CUSTOMER: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
APPOINTMENT: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[scope]}`}>
|
||||
{scope === 'CUSTOMER' ? 'Customer-Level' : 'Per Appointment'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/contracts"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 mb-4"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSignature className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold dark:text-white">{t('contracts.templates')}</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('contracts.createTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Create and manage reusable contract templates with variable placeholders
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Status Tabs */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-4">
|
||||
{(['all', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'all' ? 'All' : t(`contracts.status.${tab.toLowerCase()}`)}
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
|
||||
{statusCounts[tab]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<FileSignature className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-lg font-medium mb-2">
|
||||
{searchTerm ? 'No templates found' : t('contracts.noTemplates')}
|
||||
</p>
|
||||
<p className="text-sm mb-4">
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Create a template to get started with digital contracts'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('contracts.createTemplate')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Template
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Scope
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredTemplates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{template.name}</div>
|
||||
{template.description && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getScopeBadge(template.scope)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(template.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
v{template.version}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Fetch PDF with authentication
|
||||
const response = await apiClient.get(
|
||||
`/contracts/templates/${template.id}/preview_pdf/`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
// Create blob URL and open in new tab
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to load PDF preview:', error);
|
||||
alert('Failed to load PDF preview. PDF generation may not be available.');
|
||||
}
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Preview PDF"
|
||||
>
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(template)}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsModalOpen(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Template Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Scope *
|
||||
</label>
|
||||
<select
|
||||
value={formData.scope}
|
||||
onChange={(e) => setFormData({ ...formData, scope: e.target.value as ContractScope })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="APPOINTMENT">Per Appointment (e.g., liability waivers)</option>
|
||||
<option value="CUSTOMER">Customer-Level (e.g., terms of service)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as ContractTemplateStatus })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="ARCHIVED">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expires After (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.expires_after_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, expires_after_days: e.target.value ? parseInt(e.target.value) : null })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Leave blank for no expiration"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Brief description of this template"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contract Content (HTML) *
|
||||
</label>
|
||||
<div className="mb-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
<strong>Available Variables:</strong>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-mono">
|
||||
{['{{CUSTOMER_NAME}}', '{{CUSTOMER_EMAIL}}', '{{BUSINESS_NAME}}', '{{DATE}}', '{{YEAR}}'].map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="px-2 py-1 bg-white dark:bg-gray-700 rounded cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800"
|
||||
onClick={() => setFormData({ ...formData, content: formData.content + v })}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white font-mono text-sm"
|
||||
rows={12}
|
||||
required
|
||||
placeholder="<h1>Service Agreement</h1> <p>This agreement is between {{BUSINESS_NAME}} and {{CUSTOMER_NAME}}...</p>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTemplate.isPending || updateTemplate.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{(createTemplate.isPending || updateTemplate.isPending) && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
{editingTemplate ? 'Save Changes' : 'Create Template'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setDeleteConfirmId(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Delete Template</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
disabled={deleteTemplate.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{deleteTemplate.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractTemplates;
|
||||
892
frontend/src/pages/Contracts.tsx
Normal file
892
frontend/src/pages/Contracts.tsx
Normal file
@@ -0,0 +1,892 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FileSignature, Plus, Search, Send, Eye, X, Loader2,
|
||||
Clock, CheckCircle, XCircle, AlertCircle, Copy, RefreshCw, Ban,
|
||||
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useContracts,
|
||||
useContractTemplates,
|
||||
useCreateContract,
|
||||
useSendContract,
|
||||
useVoidContract,
|
||||
useResendContract,
|
||||
useCreateContractTemplate,
|
||||
useUpdateContractTemplate,
|
||||
useDeleteContractTemplate,
|
||||
} from '../hooks/useContracts';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
import apiClient from '../api/client';
|
||||
import { Contract, ContractTemplate, Customer, ContractTemplateStatus, ContractScope } from '../types';
|
||||
|
||||
type ContractStatus = 'PENDING' | 'SIGNED' | 'EXPIRED' | 'VOIDED';
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
scope: ContractScope;
|
||||
status: ContractTemplateStatus;
|
||||
expires_after_days: number | null;
|
||||
}
|
||||
|
||||
const Contracts: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Section collapse state
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true);
|
||||
const [contractsExpanded, setContractsExpanded] = useState(true);
|
||||
|
||||
// Contract list state
|
||||
const [contractsTab, setContractsTab] = useState<ContractStatus | 'all'>('all');
|
||||
const [contractsSearch, setContractsSearch] = useState('');
|
||||
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
|
||||
const [viewingContract, setViewingContract] = useState<Contract | null>(null);
|
||||
const [voidingContract, setVoidingContract] = useState<Contract | null>(null);
|
||||
const [voidReason, setVoidReason] = useState('');
|
||||
|
||||
// Create contract form state
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>('');
|
||||
const [selectedCustomerName, setSelectedCustomerName] = useState<string>('');
|
||||
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
|
||||
const [isCustomerDropdownOpen, setIsCustomerDropdownOpen] = useState(false);
|
||||
const [sendEmailOnCreate, setSendEmailOnCreate] = useState(true);
|
||||
const customerDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Template state
|
||||
const [templatesTab, setTemplatesTab] = useState<ContractTemplateStatus | 'all'>('all');
|
||||
const [templatesSearch, setTemplatesSearch] = useState('');
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [templateFormData, setTemplateFormData] = useState<TemplateFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
scope: 'APPOINTMENT',
|
||||
status: 'DRAFT',
|
||||
expires_after_days: null,
|
||||
});
|
||||
|
||||
// Data hooks
|
||||
const { data: contracts, isLoading: contractsLoading } = useContracts();
|
||||
const { data: allTemplates, isLoading: templatesLoading } = useContractTemplates();
|
||||
const { data: activeTemplates } = useContractTemplates('ACTIVE');
|
||||
const { data: customers = [], isLoading: customersLoading, error: customersError } = useCustomers();
|
||||
|
||||
// Contract mutations
|
||||
const createContract = useCreateContract();
|
||||
const sendContract = useSendContract();
|
||||
const voidContract = useVoidContract();
|
||||
const resendContract = useResendContract();
|
||||
|
||||
// Template mutations
|
||||
const createTemplate = useCreateContractTemplate();
|
||||
const updateTemplate = useUpdateContractTemplate();
|
||||
const deleteTemplate = useDeleteContractTemplate();
|
||||
|
||||
// Filter customers based on search
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!customers) return [];
|
||||
if (!customerSearchTerm) return customers;
|
||||
const term = customerSearchTerm.toLowerCase();
|
||||
return customers.filter(
|
||||
(c) => c.name?.toLowerCase().includes(term) || c.email?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [customers, customerSearchTerm]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (customerDropdownRef.current && !customerDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsCustomerDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter contracts
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!contracts) return [];
|
||||
return contracts.filter((contract) => {
|
||||
const matchesTab = contractsTab === 'all' || contract.status === contractsTab;
|
||||
const matchesSearch =
|
||||
contract.customer_name?.toLowerCase().includes(contractsSearch.toLowerCase()) ||
|
||||
contract.customer_email?.toLowerCase().includes(contractsSearch.toLowerCase()) ||
|
||||
contract.template_name?.toLowerCase().includes(contractsSearch.toLowerCase());
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
}, [contracts, contractsTab, contractsSearch]);
|
||||
|
||||
// Count contracts by status
|
||||
const contractStatusCounts = useMemo(() => {
|
||||
if (!contracts) return { all: 0, PENDING: 0, SIGNED: 0, EXPIRED: 0, VOIDED: 0 };
|
||||
return {
|
||||
all: contracts.length,
|
||||
PENDING: contracts.filter((c) => c.status === 'PENDING').length,
|
||||
SIGNED: contracts.filter((c) => c.status === 'SIGNED').length,
|
||||
EXPIRED: contracts.filter((c) => c.status === 'EXPIRED').length,
|
||||
VOIDED: contracts.filter((c) => c.status === 'VOIDED').length,
|
||||
};
|
||||
}, [contracts]);
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!allTemplates) return [];
|
||||
return allTemplates.filter((template) => {
|
||||
const matchesTab = templatesTab === 'all' || template.status === templatesTab;
|
||||
const matchesSearch = template.name.toLowerCase().includes(templatesSearch.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(templatesSearch.toLowerCase());
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
}, [allTemplates, templatesTab, templatesSearch]);
|
||||
|
||||
// Count templates by status
|
||||
const templateStatusCounts = useMemo(() => {
|
||||
if (!allTemplates) return { all: 0, ACTIVE: 0, DRAFT: 0, ARCHIVED: 0 };
|
||||
return {
|
||||
all: allTemplates.length,
|
||||
ACTIVE: allTemplates.filter((t) => t.status === 'ACTIVE').length,
|
||||
DRAFT: allTemplates.filter((t) => t.status === 'DRAFT').length,
|
||||
ARCHIVED: allTemplates.filter((t) => t.status === 'ARCHIVED').length,
|
||||
};
|
||||
}, [allTemplates]);
|
||||
|
||||
// Handlers
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
setSelectedCustomerId(customer.userId || customer.id);
|
||||
setSelectedCustomerName(`${customer.name} (${customer.email})`);
|
||||
setCustomerSearchTerm('');
|
||||
setIsCustomerDropdownOpen(false);
|
||||
};
|
||||
|
||||
const clearCustomerSelection = () => {
|
||||
setSelectedCustomerId('');
|
||||
setSelectedCustomerName('');
|
||||
setCustomerSearchTerm('');
|
||||
};
|
||||
|
||||
const handleCreateContract = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedTemplateId || !selectedCustomerId) return;
|
||||
|
||||
try {
|
||||
const result = await createContract.mutateAsync({
|
||||
template: selectedTemplateId,
|
||||
customer: selectedCustomerId,
|
||||
});
|
||||
|
||||
if (sendEmailOnCreate && result.id) {
|
||||
await sendContract.mutateAsync(String(result.id));
|
||||
}
|
||||
|
||||
setIsCreateContractModalOpen(false);
|
||||
setSelectedTemplateId('');
|
||||
setSelectedCustomerId('');
|
||||
setSelectedCustomerName('');
|
||||
setCustomerSearchTerm('');
|
||||
setSendEmailOnCreate(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to create contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendContract = async (id: string) => {
|
||||
try {
|
||||
await sendContract.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to send contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendContract = async (id: string) => {
|
||||
try {
|
||||
await resendContract.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to resend contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoidContract = async () => {
|
||||
if (!voidingContract || !voidReason.trim()) return;
|
||||
|
||||
try {
|
||||
await voidContract.mutateAsync({
|
||||
id: voidingContract.id,
|
||||
reason: voidReason,
|
||||
});
|
||||
setVoidingContract(null);
|
||||
setVoidReason('');
|
||||
} catch (error) {
|
||||
console.error('Failed to void contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copySigningLink = (contract: Contract) => {
|
||||
if (contract.public_token) {
|
||||
const link = `${window.location.origin}/sign/${contract.public_token}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
}
|
||||
};
|
||||
|
||||
// Template handlers
|
||||
const resetTemplateForm = () => {
|
||||
setTemplateFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
scope: 'APPOINTMENT',
|
||||
status: 'DRAFT',
|
||||
expires_after_days: null,
|
||||
});
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const openCreateTemplateModal = () => {
|
||||
resetTemplateForm();
|
||||
setIsTemplateModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditTemplateModal = (template: ContractTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setTemplateFormData({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
scope: template.scope,
|
||||
status: template.status,
|
||||
expires_after_days: template.expires_after_days,
|
||||
});
|
||||
setIsTemplateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleTemplateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingTemplate) {
|
||||
await updateTemplate.mutateAsync({
|
||||
id: editingTemplate.id,
|
||||
updates: templateFormData,
|
||||
});
|
||||
} else {
|
||||
await createTemplate.mutateAsync(templateFormData);
|
||||
}
|
||||
setIsTemplateModalOpen(false);
|
||||
resetTemplateForm();
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (id: string) => {
|
||||
try {
|
||||
await deleteTemplate.mutateAsync(id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Badge renderers
|
||||
const getContractStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; icon: React.ReactNode }> = {
|
||||
PENDING: { bg: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', icon: <Clock size={14} /> },
|
||||
SIGNED: { bg: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', icon: <CheckCircle size={14} /> },
|
||||
EXPIRED: { bg: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400', icon: <XCircle size={14} /> },
|
||||
VOIDED: { bg: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', icon: <Ban size={14} /> },
|
||||
};
|
||||
const style = styles[status] || styles.PENDING;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${style.bg}`}>
|
||||
{style.icon}
|
||||
{t(`contracts.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getTemplateStatusBadge = (status: ContractTemplateStatus) => {
|
||||
const styles = {
|
||||
ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
DRAFT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
ARCHIVED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||
{t(`contracts.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getScopeBadge = (scope: ContractScope) => {
|
||||
const styles = {
|
||||
CUSTOMER: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
APPOINTMENT: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[scope]}`}>
|
||||
{scope === 'CUSTOMER' ? 'Customer-Level' : 'Per Appointment'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (contractsLoading && templatesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileSignature className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold dark:text-white">{t('contracts.title')}</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('contracts.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Templates Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{templatesExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('contracts.templates')}
|
||||
</h2>
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{allTemplates?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openCreateTemplateModal();
|
||||
}}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Template
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{templatesExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
{/* Template Search and Tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={templatesSearch}
|
||||
onChange={(e) => setTemplatesSearch(e.target.value)}
|
||||
placeholder="Search templates..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setTemplatesTab(tab)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
templatesTab === tab
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'all' ? 'All' : tab.charAt(0) + tab.slice(1).toLowerCase()}
|
||||
<span className="ml-1 text-xs">({templateStatusCounts[tab]})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates Table */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<FileSignature className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p>{templatesSearch ? 'No templates found' : 'No templates yet. Create your first template to get started.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Template</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scope</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Version</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredTemplates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{template.name}</div>
|
||||
{template.description && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">{template.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{getScopeBadge(template.scope)}</td>
|
||||
<td className="px-4 py-3">{getTemplateStatusBadge(template.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">v{template.version}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/contracts/templates/${template.id}/preview_pdf/`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to load PDF preview:', error);
|
||||
alert('Failed to load PDF preview.');
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Preview PDF"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditTemplateModal(template)}
|
||||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contracts Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setContractsExpanded(!contractsExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{contractsExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Sent Contracts
|
||||
</h2>
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{contracts?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCreateContractModalOpen(true);
|
||||
}}
|
||||
disabled={!activeTemplates || activeTemplates.length === 0}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Send Contract
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{contractsExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
{/* Contract Search and Tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={contractsSearch}
|
||||
onChange={(e) => setContractsSearch(e.target.value)}
|
||||
placeholder="Search contracts..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(['all', 'PENDING', 'SIGNED', 'EXPIRED', 'VOIDED'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setContractsTab(tab)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
contractsTab === tab
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'all' ? 'All' : tab.charAt(0) + tab.slice(1).toLowerCase()}
|
||||
<span className="ml-1 text-xs">({contractStatusCounts[tab]})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contracts Table */}
|
||||
{filteredContracts.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<FileSignature className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p>{contractsSearch ? 'No contracts found' : 'No contracts sent yet.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Contract</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Created</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredContracts.map((contract) => (
|
||||
<tr key={contract.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{contract.customer_name || 'Unknown'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{contract.customer_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{contract.template_name}</div>
|
||||
{contract.sent_at && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Sent: {formatDate(contract.sent_at)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getContractStatusBadge(contract.status)}
|
||||
{contract.signed_at && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Signed: {formatDate(contract.signed_at)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{formatDate(contract.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setViewingContract(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="View Details">
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{contract.status === 'PENDING' && (
|
||||
<>
|
||||
<button onClick={() => copySigningLink(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Copy Signing Link">
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
{!contract.sent_at ? (
|
||||
<button onClick={() => handleSendContract(contract.id)} disabled={sendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Send Email">
|
||||
<Send size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => handleResendContract(contract.id)} disabled={resendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Resend Email">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setVoidingContract(contract)} className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Void Contract">
|
||||
<Ban size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{contract.public_token && contract.status === 'PENDING' && (
|
||||
<a href={`/sign/${contract.public_token}`} target="_blank" rel="noopener noreferrer" className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title="Open Signing Page">
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Contract Modal */}
|
||||
{isCreateContractModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsCreateContractModalOpen(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Send Contract</h2>
|
||||
<button onClick={() => setIsCreateContractModalOpen(false)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateContract} className="p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contract Template *</label>
|
||||
<select value={selectedTemplateId} onChange={(e) => setSelectedTemplateId(e.target.value)} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
|
||||
<option value="">Select a template...</option>
|
||||
{activeTemplates?.map((template) => (
|
||||
<option key={template.id} value={template.id}>{template.name} (v{template.version})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer *</label>
|
||||
<div className="relative" ref={customerDropdownRef}>
|
||||
{selectedCustomerId ? (
|
||||
<div className="flex items-center gap-2 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700">
|
||||
<User size={16} className="text-gray-400" />
|
||||
<span className="flex-1 text-gray-900 dark:text-white truncate">{selectedCustomerName}</span>
|
||||
<button type="button" onClick={clearCustomerSelection} className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input type="text" value={customerSearchTerm} onChange={(e) => { setCustomerSearchTerm(e.target.value); setIsCustomerDropdownOpen(true); }} onFocus={() => setIsCustomerDropdownOpen(true)} placeholder="Search customers..." className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" />
|
||||
<ChevronDown size={16} className={`absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-transform ${isCustomerDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{isCustomerDropdownOpen && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{customersLoading ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading customers...
|
||||
</div>
|
||||
) : customersError ? (
|
||||
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||
Failed to load customers
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customers.length === 0 ? 'No customers available. Create customers first.' : 'No matching customers'}
|
||||
</div>
|
||||
) : (
|
||||
filteredCustomers.map((customer) => (
|
||||
<button key={customer.id} type="button" onClick={() => handleSelectCustomer(customer)} className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<User size={14} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">{customer.name || 'Unnamed'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">{customer.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="sendEmail" checked={sendEmailOnCreate} onChange={(e) => setSendEmailOnCreate(e.target.checked)} className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
|
||||
<label htmlFor="sendEmail" className="text-sm text-gray-700 dark:text-gray-300">Send signing request email immediately</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={() => setIsCreateContractModalOpen(false)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={createContract.isPending || !selectedTemplateId || !selectedCustomerId} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{createContract.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Send Contract
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Contract Modal */}
|
||||
{viewingContract && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setViewingContract(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Contract Details</h2>
|
||||
<button onClick={() => setViewingContract(null)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Customer</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{viewingContract.customer_name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{viewingContract.customer_email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Template</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{viewingContract.template_name} (v{viewingContract.template_version})</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
|
||||
{getContractStatusBadge(viewingContract.status)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Created</p>
|
||||
<p className="text-gray-900 dark:text-white">{formatDate(viewingContract.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{viewingContract.content && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">Content Preview</p>
|
||||
<div className="prose dark:prose-invert max-w-none p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: viewingContract.content }} />
|
||||
</div>
|
||||
)}
|
||||
{viewingContract.public_token && viewingContract.status === 'PENDING' && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">Signing Link</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" readOnly value={`${window.location.origin}/sign/${viewingContract.public_token}`} className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 rounded" />
|
||||
<button onClick={() => copySigningLink(viewingContract)} className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Void Contract Modal */}
|
||||
{voidingContract && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setVoidingContract(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Void Contract</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Voiding this contract will cancel it. The customer will no longer be able to sign.</p>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason for voiding *</label>
|
||||
<textarea value={voidReason} onChange={(e) => setVoidReason(e.target.value)} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent dark:bg-gray-700 dark:text-white" rows={3} placeholder="Enter the reason..." required />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => { setVoidingContract(null); setVoidReason(''); }} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
|
||||
<button onClick={handleVoidContract} disabled={voidContract.isPending || !voidReason.trim()} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{voidContract.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Void Contract
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Template Modal */}
|
||||
{isTemplateModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setIsTemplateModalOpen(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2>
|
||||
<button onClick={() => setIsTemplateModalOpen(false)} className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleTemplateSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Template Name *</label>
|
||||
<input type="text" value={templateFormData.name} onChange={(e) => setTemplateFormData({ ...templateFormData, name: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Scope *</label>
|
||||
<select value={templateFormData.scope} onChange={(e) => setTemplateFormData({ ...templateFormData, scope: e.target.value as ContractScope })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="APPOINTMENT">Per Appointment (e.g., liability waivers)</option>
|
||||
<option value="CUSTOMER">Customer-Level (e.g., terms of service)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
||||
<select value={templateFormData.status} onChange={(e) => setTemplateFormData({ ...templateFormData, status: e.target.value as ContractTemplateStatus })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="ARCHIVED">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expires After (days)</label>
|
||||
<input type="number" value={templateFormData.expires_after_days || ''} onChange={(e) => setTemplateFormData({ ...templateFormData, expires_after_days: e.target.value ? parseInt(e.target.value) : null })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" placeholder="Leave blank for no expiration" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<input type="text" value={templateFormData.description} onChange={(e) => setTemplateFormData({ ...templateFormData, description: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" placeholder="Brief description of this template" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contract Content (HTML) *</label>
|
||||
<div className="mb-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2"><strong>Available Variables:</strong></p>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-mono">
|
||||
{['{{CUSTOMER_NAME}}', '{{CUSTOMER_EMAIL}}', '{{BUSINESS_NAME}}', '{{DATE}}', '{{YEAR}}'].map((v) => (
|
||||
<span key={v} className="px-2 py-1 bg-white dark:bg-gray-700 rounded cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800" onClick={() => setTemplateFormData({ ...templateFormData, content: templateFormData.content + v })}>{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={templateFormData.content} onChange={(e) => setTemplateFormData({ ...templateFormData, content: e.target.value })} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white font-mono text-sm" rows={12} required placeholder="<h1>Service Agreement</h1> <p>This agreement is between {{BUSINESS_NAME}} and {{CUSTOMER_NAME}}...</p>" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={() => setIsTemplateModalOpen(false)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={createTemplate.isPending || updateTemplate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{(createTemplate.isPending || updateTemplate.isPending) && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{editingTemplate ? 'Save Changes' : 'Create Template'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Template Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setDeleteConfirmId(null)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Delete Template</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">Are you sure you want to delete this template? This action cannot be undone.</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDeleteConfirmId(null)} className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Cancel</button>
|
||||
<button onClick={() => handleDeleteTemplate(deleteConfirmId)} disabled={deleteTemplate.isPending} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{deleteTemplate.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contracts;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
CalendarOff,
|
||||
CheckSquare,
|
||||
Users,
|
||||
Briefcase,
|
||||
@@ -56,6 +57,7 @@ const HelpGuide: React.FC = () => {
|
||||
{ label: 'Services', path: '/help/services', icon: <Briefcase size={18} /> },
|
||||
{ label: 'Resources', path: '/help/resources', icon: <ClipboardList size={18} /> },
|
||||
{ label: 'Staff', path: '/help/staff', icon: <UserCog size={18} /> },
|
||||
{ label: 'Time Blocks', path: '/help/time-blocks', icon: <CalendarOff size={18} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
499
frontend/src/pages/HelpTimeBlocks.tsx
Normal file
499
frontend/src/pages/HelpTimeBlocks.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Help Time Blocks - Comprehensive guide for time blocking features
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarOff,
|
||||
Building2,
|
||||
User,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
Ban,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
Repeat,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
|
||||
const HelpTimeBlocks: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('helpTimeBlocks.title', 'Time Blocks Guide')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('helpTimeBlocks.subtitle', 'Learn how to block off time for closures, holidays, and unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<CalendarOff size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.overview.title', 'What are Time Blocks?')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Time blocks allow you to mark specific dates, times, or recurring periods as unavailable for bookings.
|
||||
Use them to manage holidays, business closures, staff vacations, maintenance windows, and more.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Building2 size={20} className="text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Business Blocks</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Apply to all resources. Perfect for company holidays, office closures, and maintenance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<User size={20} className="text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Resource Blocks</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Apply to specific resources. Use for individual vacations, appointments, or training.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Ban size={20} className="text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Hard Blocks</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completely prevent bookings during the blocked period. Cannot be overridden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<AlertCircle size={20} className="text-yellow-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Soft Blocks</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Show a warning but still allow bookings with confirmation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Block Levels Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Building2 size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.levels.title', 'Block Levels')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Level</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scope</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Example Uses</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 size={16} className="text-blue-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Business</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
All resources in your business
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Holidays, office closures, company events, maintenance
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={16} className="text-green-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Resource</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
A specific resource (staff member, room, etc.)
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Vacation, personal appointments, lunch breaks, training
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800 dark:text-blue-300">Blocks are Additive</h4>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400">
|
||||
Both business-level and resource-level blocks apply. If the business is closed on a holiday,
|
||||
individual resource blocks don't matter for that day.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Block Types Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Ban size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.types.title', 'Block Types: Hard vs Soft')}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Ban size={24} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Hard Block</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
Completely prevents any bookings during the blocked period. Customers cannot book,
|
||||
and staff cannot override. The scheduler shows a striped red overlay.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 text-xs rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">Cannot be overridden</span>
|
||||
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Shows in customer booking</span>
|
||||
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Red striped overlay</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle size={24} className="text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Soft Block</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
Shows a warning but allows bookings with confirmation. Useful for indicating
|
||||
preferred-off times that can be overridden if necessary.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 text-xs rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300">Can be overridden</span>
|
||||
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Shows warning only</span>
|
||||
<span className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Yellow dashed overlay</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recurrence Patterns Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Repeat size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.recurrence.title', 'Recurrence Patterns')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pattern</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Example</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">One-time</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
A specific date or date range that occurs once
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Dec 24-26 (Christmas break), Feb 15 (President's Day)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">Weekly</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Repeats on specific days of the week
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Every Saturday and Sunday, Every Monday lunch
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">Monthly</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Repeats on specific days of the month
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
1st of every month (inventory), 15th (payroll)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">Yearly</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Repeats on a specific month and day each year
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
July 4th, December 25th, January 1st
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Holiday</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Select from popular US holidays. Multi-select supported - each holiday creates its own block.
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Christmas, Thanksgiving, Memorial Day, Independence Day
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Visualization Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Eye size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.visualization.title', 'Viewing Time Blocks')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Time blocks appear in multiple views throughout the application with color-coded indicators:
|
||||
</p>
|
||||
|
||||
{/* Color Legend */}
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Color Legend</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 rounded" style={{ background: 'repeating-linear-gradient(-45deg, rgba(239, 68, 68, 0.3), rgba(239, 68, 68, 0.3) 3px, rgba(239, 68, 68, 0.5) 3px, rgba(239, 68, 68, 0.5) 6px)' }}></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Business Hard Block</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-red-500 text-white rounded font-semibold">B</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 rounded bg-yellow-200 dark:bg-yellow-900/50 border border-dashed border-yellow-500"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Business Soft Block</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-yellow-500 text-white rounded font-semibold">B</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 rounded" style={{ background: 'repeating-linear-gradient(-45deg, rgba(147, 51, 234, 0.25), rgba(147, 51, 234, 0.25) 3px, rgba(147, 51, 234, 0.4) 3px, rgba(147, 51, 234, 0.4) 6px)' }}></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Resource Hard Block</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-purple-500 text-white rounded font-semibold">R</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 rounded bg-cyan-200 dark:bg-cyan-900/50 border border-dashed border-cyan-500"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Resource Soft Block</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-cyan-500 text-white rounded font-semibold">R</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<CalendarDays size={20} className="text-brand-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Scheduler Overlay</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Blocked times appear directly on the scheduler calendar with visual indicators.
|
||||
Business blocks use red/yellow colors, resource blocks use purple/cyan.
|
||||
Click on any blocked area in week view to navigate to that day.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<CalendarOff size={20} className="text-brand-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Month View</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Blocked dates show with colored backgrounds and badge indicators.
|
||||
Multiple block types on the same day show all applicable badges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Clock size={20} className="text-brand-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">List View</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage all time blocks in a tabular format with filtering options.
|
||||
Edit, activate/deactivate, or delete blocks from here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Staff Availability Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<User size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.staffAvailability.title', 'Staff Availability (My Availability)')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Staff members can manage their own time blocks through the "My Availability" page.
|
||||
This allows them to block off time for personal appointments, vacations, or other commitments.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">View business-level blocks (read-only)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create and manage personal time blocks</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">See yearly calendar of their availability</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle size={20} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800 dark:text-yellow-300">Hard Block Permission</h4>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-400">
|
||||
By default, staff can only create soft blocks. To allow a staff member to create hard blocks,
|
||||
enable the "Can create hard blocks" permission in their staff settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Lightbulb size={20} className="text-brand-500" />
|
||||
{t('helpTimeBlocks.bestPractices.title', 'Best Practices')}
|
||||
</h2>
|
||||
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">1</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Plan holidays in advance</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Set up annual holidays at the beginning of each year using the Holiday recurrence type.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">2</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Use soft blocks for preferences</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Reserve hard blocks for absolute closures. Use soft blocks for preferred-off times that could be overridden.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">3</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Check for conflicts before creating</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
The system shows existing appointments that conflict with new blocks. Review before confirming.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">4</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Set recurrence end dates</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
For recurring blocks that aren't permanent, set an end date to prevent them from extending indefinitely.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">5</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Use descriptive titles</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Include clear titles like "Christmas Day", "Team Meeting", or "Annual Maintenance" for easy identification.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Access Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('helpTimeBlocks.quickAccess.title', 'Quick Access')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a
|
||||
href="/time-blocks"
|
||||
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 size={20} className="text-brand-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Manage Time Blocks</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-400" />
|
||||
</a>
|
||||
<a
|
||||
href="/my-availability"
|
||||
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<User size={20} className="text-brand-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">My Availability</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-400" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpTimeBlocks;
|
||||
789
frontend/src/pages/MyAvailability.tsx
Normal file
789
frontend/src/pages/MyAvailability.tsx
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* My Availability Page
|
||||
*
|
||||
* Staff-facing page to view and manage their own time blocks.
|
||||
* Shows business-level blocks (read-only) and personal blocks (editable).
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
TimeBlockListItem,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
User,
|
||||
} from '../types';
|
||||
import {
|
||||
useMyBlocks,
|
||||
useCreateTimeBlock,
|
||||
useUpdateTimeBlock,
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
CreateTimeBlockData,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import Portal from '../components/Portal';
|
||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||
import {
|
||||
Calendar,
|
||||
Building2,
|
||||
User as UserIcon,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
Ban,
|
||||
AlertCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||
NONE: 'One-time',
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
YEARLY: 'Yearly',
|
||||
HOLIDAY: 'Holiday',
|
||||
};
|
||||
|
||||
const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
HARD: 'Hard Block',
|
||||
SOFT: 'Soft Block',
|
||||
};
|
||||
|
||||
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
interface TimeBlockFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
all_day: boolean;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
recurrence_pattern: RecurrencePattern;
|
||||
recurrence_start: string;
|
||||
recurrence_end: string;
|
||||
}
|
||||
|
||||
const defaultFormData: TimeBlockFormData = {
|
||||
title: '',
|
||||
description: '',
|
||||
block_type: 'SOFT',
|
||||
recurrence_type: 'NONE',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
};
|
||||
|
||||
interface MyAvailabilityProps {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
||||
const user = props.user || contextUser;
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Fetch data
|
||||
const { data: myBlocksData, isLoading } = useMyBlocks();
|
||||
const { data: holidays = [] } = useHolidays('US');
|
||||
|
||||
// Mutations
|
||||
const createBlock = useCreateTimeBlock();
|
||||
const updateBlock = useUpdateTimeBlock();
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
|
||||
// Check if user can create hard blocks
|
||||
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
|
||||
|
||||
// Modal handlers
|
||||
const openCreateModal = () => {
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (block: TimeBlockListItem) => {
|
||||
setEditingBlock(block);
|
||||
setFormData({
|
||||
title: block.title,
|
||||
description: '',
|
||||
block_type: block.block_type,
|
||||
recurrence_type: block.recurrence_type,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
};
|
||||
|
||||
// Form handlers
|
||||
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDayOfWeekToggle = (day: number) => {
|
||||
const current = formData.recurrence_pattern.days_of_week || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort();
|
||||
handlePatternChange('days_of_week', newDays);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!myBlocksData?.resource_id) {
|
||||
console.error('No resource linked to user');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateTimeBlockData = {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
resource: myBlocksData.resource_id,
|
||||
block_type: formData.block_type,
|
||||
recurrence_type: formData.recurrence_type,
|
||||
all_day: formData.all_day,
|
||||
};
|
||||
|
||||
// Add type-specific fields
|
||||
if (formData.recurrence_type === 'NONE') {
|
||||
payload.start_date = formData.start_date;
|
||||
payload.end_date = formData.end_date || formData.start_date;
|
||||
}
|
||||
|
||||
if (!formData.all_day) {
|
||||
payload.start_time = formData.start_time;
|
||||
payload.end_time = formData.end_time;
|
||||
}
|
||||
|
||||
if (formData.recurrence_type !== 'NONE') {
|
||||
payload.recurrence_pattern = formData.recurrence_pattern;
|
||||
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
|
||||
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
|
||||
} else {
|
||||
await createBlock.mutateAsync(payload);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteBlock.mutateAsync(id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
try {
|
||||
await toggleBlock.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render block type badge
|
||||
const renderBlockTypeBadge = (type: BlockType) => (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
type === 'HARD'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{type === 'HARD' ? <Ban size={12} className="mr-1" /> : <AlertCircle size={12} className="mr-1" />}
|
||||
{BLOCK_TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Render recurrence badge
|
||||
const renderRecurrenceBadge = (type: RecurrenceType) => (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{type === 'HOLIDAY' ? (
|
||||
<CalendarDays size={12} className="mr-1" />
|
||||
) : type === 'NONE' ? (
|
||||
<Clock size={12} className="mr-1" />
|
||||
) : (
|
||||
<Calendar size={12} className="mr-1" />
|
||||
)}
|
||||
{RECURRENCE_TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Handle no linked resource
|
||||
if (!isLoading && !myBlocksData?.resource_id) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('myAvailability.title', 'My Availability')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<AlertTriangle size={48} className="mx-auto text-yellow-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('myAvailability.noResource', 'No Resource Linked')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t('myAvailability.noResourceDesc', 'Your account is not linked to a resource. Please contact your manager to set up your availability.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('myAvailability.title', 'My Availability')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{myBlocksData?.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon size={14} />
|
||||
{myBlocksData.resource_name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addBlock', 'Block Time')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Business Blocks (Read-only) */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
|
||||
<Info size={16} />
|
||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData.business_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Blocks (Editable) */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={20} />
|
||||
{t('myAvailability.myBlocks', 'My Time Blocks')}
|
||||
</h2>
|
||||
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('myAvailability.noBlocks', 'No Time Blocks')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
||||
</p>
|
||||
<button onClick={openCreateModal} className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.titleCol', 'Title')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.typeCol', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.patternCol', 'Pattern')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.actionsCol', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData?.my_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(block)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yearly Calendar View */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<YearlyBlockCalendar
|
||||
resourceId={myBlocksData?.resource_id}
|
||||
onBlockClick={(blockId) => {
|
||||
// Find the block and open edit modal if it's my block
|
||||
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
|
||||
if (block) {
|
||||
openEditModal(block);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{editingBlock
|
||||
? t('myAvailability.editBlock', 'Edit Time Block')
|
||||
: t('myAvailability.createBlock', 'Block Time Off')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.title', 'Title')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
placeholder="e.g., Vacation, Lunch Break, Doctor Appointment"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.description', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
rows={2}
|
||||
placeholder="Optional reason"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Block Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.blockType', 'Block Type')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="SOFT"
|
||||
checked={formData.block_type === 'SOFT'}
|
||||
onChange={() => handleFormChange('block_type', 'SOFT')}
|
||||
className="text-brand-500"
|
||||
/>
|
||||
<AlertCircle size={16} className="text-yellow-500" />
|
||||
<span className="text-sm">Soft Block</span>
|
||||
<span className="text-xs text-gray-500">(shows warning, can be overridden)</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 ${canCreateHardBlocks ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="HARD"
|
||||
checked={formData.block_type === 'HARD'}
|
||||
onChange={() => canCreateHardBlocks && handleFormChange('block_type', 'HARD')}
|
||||
className="text-brand-500"
|
||||
disabled={!canCreateHardBlocks}
|
||||
/>
|
||||
<Ban size={16} className="text-red-500" />
|
||||
<span className="text-sm">Hard Block</span>
|
||||
<span className="text-xs text-gray-500">(prevents booking)</span>
|
||||
{!canCreateHardBlocks && (
|
||||
<span className="text-xs text-red-500">(requires permission)</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.recurrenceType', 'Recurrence')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.recurrence_type}
|
||||
onChange={(e) => handleFormChange('recurrence_type', e.target.value as RecurrenceType)}
|
||||
className="input-primary w-full"
|
||||
>
|
||||
<option value="NONE">One-time (specific date/range)</option>
|
||||
<option value="WEEKLY">Weekly (e.g., every Monday)</option>
|
||||
<option value="MONTHLY">Monthly (e.g., 1st of month)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Pattern - NONE */}
|
||||
{formData.recurrence_type === 'NONE' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => handleFormChange('start_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => handleFormChange('end_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
min={formData.start_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - WEEKLY */}
|
||||
{formData.recurrence_type === 'WEEKLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Week *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAY_ABBREVS.map((day, index) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_week || []).includes(index);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => handleDayOfWeekToggle(index)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - MONTHLY */}
|
||||
{formData.recurrence_type === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Month *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_month || []).includes(day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = formData.recurrence_pattern.days_of_month || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort((a, b) => a - b);
|
||||
handlePatternChange('days_of_month', newDays);
|
||||
}}
|
||||
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="all_day"
|
||||
checked={formData.all_day}
|
||||
onChange={(e) => handleFormChange('all_day', e.target.checked)}
|
||||
className="rounded text-brand-500"
|
||||
/>
|
||||
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('myAvailability.form.allDay', 'All day')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Time Range (if not all day) */}
|
||||
{!formData.all_day && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.start_time}
|
||||
onChange={(e) => handleFormChange('start_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.end_time}
|
||||
onChange={(e) => handleFormChange('end_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={closeModal} className="btn-secondary">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={createBlock.isPending || updateBlock.isPending}
|
||||
>
|
||||
{(createBlock.isPending || updateBlock.isPending) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.saving', 'Saving...')}
|
||||
</span>
|
||||
) : editingBlock ? (
|
||||
t('common.save', 'Save Changes')
|
||||
) : (
|
||||
t('myAvailability.create', 'Block Time')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
|
||||
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('myAvailability.deleteConfirmTitle', 'Delete Time Block?')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('myAvailability.deleteConfirmDesc', 'This action cannot be undone.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="btn-danger"
|
||||
disabled={deleteBlock.isPending}
|
||||
>
|
||||
{deleteBlock.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.deleting', 'Deleting...')}
|
||||
</span>
|
||||
) : (
|
||||
t('common.delete', 'Delete')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAvailability;
|
||||
@@ -9,8 +9,10 @@ import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
|
||||
import { useBlockedDates } from '../hooks/useTimeBlocks';
|
||||
import Portal from '../components/Portal';
|
||||
import EventAutomations from '../components/EventAutomations';
|
||||
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
|
||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||
|
||||
// Time settings
|
||||
@@ -83,6 +85,14 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
const updateMutation = useUpdateAppointment();
|
||||
const deleteMutation = useDeleteAppointment();
|
||||
|
||||
// Fetch blocked dates for the calendar overlay
|
||||
const blockedDatesParams = useMemo(() => ({
|
||||
start_date: dateRange.startDate.toISOString().split('T')[0],
|
||||
end_date: dateRange.endDate.toISOString().split('T')[0],
|
||||
include_business: true,
|
||||
}), [dateRange]);
|
||||
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);
|
||||
|
||||
// Calculate over-quota resources (will be auto-archived when grace period ends)
|
||||
const overQuotaResourceIds = useMemo(
|
||||
() => getOverQuotaResourceIds(resources as Resource[], user.quota_overages),
|
||||
@@ -1239,27 +1249,71 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
const displayedAppointments = dayAppointments.slice(0, 3);
|
||||
const remainingCount = dayAppointments.length - 3;
|
||||
|
||||
// Check if this date has any blocks
|
||||
const dateBlocks = date ? blockedDates.filter(b => {
|
||||
// Parse date string as local date, not UTC
|
||||
const [year, month, dayNum] = b.date.split('-').map(Number);
|
||||
const blockDate = new Date(year, month - 1, dayNum);
|
||||
blockDate.setHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(date);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
return blockDate.getTime() === checkDate.getTime();
|
||||
}) : [];
|
||||
|
||||
// Separate business and resource blocks
|
||||
const businessBlocks = dateBlocks.filter(b => b.resource_id === null);
|
||||
const hasBusinessHard = businessBlocks.some(b => b.block_type === 'HARD');
|
||||
const hasBusinessSoft = businessBlocks.some(b => b.block_type === 'SOFT');
|
||||
|
||||
// Group resource blocks by resource - maintain resource order
|
||||
const resourceBlocksByResource = resources.map(resource => {
|
||||
const blocks = dateBlocks.filter(b => b.resource_id === resource.id);
|
||||
return {
|
||||
resource,
|
||||
blocks,
|
||||
hasHard: blocks.some(b => b.block_type === 'HARD'),
|
||||
hasSoft: blocks.some(b => b.block_type === 'SOFT'),
|
||||
};
|
||||
}).filter(rb => rb.blocks.length > 0);
|
||||
|
||||
// Determine background color - only business blocks affect the whole cell now
|
||||
const getBgClass = () => {
|
||||
if (date && date.getMonth() !== viewDate.getMonth()) return 'bg-gray-100 dark:bg-gray-800/70 opacity-50';
|
||||
if (hasBusinessHard) return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (hasBusinessSoft) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||
if (date) return 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800';
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`min-h-[120px] p-2 transition-colors relative ${
|
||||
date && date.getMonth() !== viewDate.getMonth()
|
||||
? 'bg-gray-100 dark:bg-gray-800/70 opacity-50'
|
||||
: date
|
||||
? 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50'
|
||||
} ${monthDropTarget?.date.getTime() === date?.getTime() && date?.getMonth() === viewDate.getMonth() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
|
||||
className={`min-h-[120px] p-2 transition-colors relative ${getBgClass()} ${monthDropTarget?.date.getTime() === date?.getTime() && date?.getMonth() === viewDate.getMonth() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
|
||||
onClick={() => { if (date) { setViewDate(date); setViewMode('day'); } }}
|
||||
onDragOver={(e) => date && handleMonthCellDragOver(e, date)}
|
||||
>
|
||||
{date && (
|
||||
<>
|
||||
<div className={`text-sm font-medium mb-1 ${
|
||||
isToday
|
||||
? 'w-7 h-7 flex items-center justify-center rounded-full bg-brand-500 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{date.getDate()}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className={`text-sm font-medium ${
|
||||
isToday
|
||||
? 'w-7 h-7 flex items-center justify-center rounded-full bg-brand-500 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{hasBusinessHard && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-red-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'HARD')?.title}>
|
||||
B
|
||||
</span>
|
||||
)}
|
||||
{!hasBusinessHard && hasBusinessSoft && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-yellow-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'SOFT')?.title}>
|
||||
B
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayedAppointments.map(apt => {
|
||||
@@ -1291,6 +1345,50 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Resource block indicators - each resource gets 1/n of the row */}
|
||||
{resourceBlocksByResource.length > 0 && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 flex"
|
||||
style={{ height: `${Math.min(20, 60 / resources.length)}px` }}
|
||||
>
|
||||
{resources.map((resource, resourceIndex) => {
|
||||
const resourceBlockInfo = resourceBlocksByResource.find(rb => rb.resource.id === resource.id);
|
||||
if (!resourceBlockInfo) {
|
||||
// Empty slot for this resource
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="flex-1"
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { hasHard, blocks } = resourceBlockInfo;
|
||||
const bgColor = hasHard
|
||||
? 'bg-purple-400 dark:bg-purple-600'
|
||||
: 'bg-cyan-400 dark:bg-cyan-500';
|
||||
const borderStyle = hasHard ? '' : 'border-t-2 border-dashed border-cyan-600';
|
||||
const title = blocks.map(b => b.title).join(', ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className={`flex-1 ${bgColor} ${borderStyle} flex items-center justify-center overflow-hidden`}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
marginLeft: resourceIndex > 0 ? '1px' : 0,
|
||||
}}
|
||||
title={`${resource.name}: ${title}`}
|
||||
>
|
||||
<span className="text-[8px] text-white font-bold truncate px-0.5">
|
||||
{resource.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1570,6 +1668,12 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
{viewMode !== 'month' && (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
|
||||
{/* Match timeline header height - add extra row for week view */}
|
||||
{viewMode !== 'day' && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-700/50 px-4 py-2 text-sm font-semibold text-gray-500 dark:text-gray-400 shrink-0">
|
||||
{viewDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
@@ -1647,8 +1751,9 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
{days.map((day, dayIndex) => (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50"
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
style={{ width: dayWidth }}
|
||||
onClick={() => { setViewDate(day); setViewMode('day'); }}
|
||||
>
|
||||
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
@@ -1701,10 +1806,35 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
</div>
|
||||
{resourceLayouts.map(layout => {
|
||||
const isResourceOverQuota = overQuotaResourceIds.has(layout.resource.id);
|
||||
return (<div key={layout.resource.id} className={`relative border-b border-gray-100 dark:border-gray-800 transition-colors ${isResourceOverQuota ? 'bg-amber-50/30 dark:bg-amber-900/10' : ''}`} style={{ height: layout.height }}>{layout.appointments.map(apt => {
|
||||
return (<div key={layout.resource.id} className={`relative border-b border-gray-100 dark:border-gray-800 transition-colors ${isResourceOverQuota ? 'bg-amber-50/30 dark:bg-amber-900/10' : ''}`} style={{ height: layout.height }}>
|
||||
{/* Clickable day columns for week view - navigate to day view */}
|
||||
{viewMode === 'week' && days.map((day, dayIndex) => (
|
||||
<div
|
||||
key={`day-click-${dayIndex}`}
|
||||
className="absolute top-0 bottom-0 cursor-pointer hover:bg-brand-500/5 transition-colors"
|
||||
style={{ left: dayIndex * dayWidth, width: dayWidth }}
|
||||
onClick={() => { setViewDate(day); setViewMode('day'); }}
|
||||
/>
|
||||
))}
|
||||
{/* Time Block Overlays */}
|
||||
{blockedDates.length > 0 && (
|
||||
<TimeBlockCalendarOverlay
|
||||
blockedDates={blockedDates}
|
||||
resourceId={layout.resource.id}
|
||||
viewDate={viewDate}
|
||||
zoomLevel={zoomLevel}
|
||||
pixelsPerMinute={PIXELS_PER_MINUTE}
|
||||
startHour={START_HOUR}
|
||||
dayWidth={dayWidth}
|
||||
laneHeight={layout.height}
|
||||
days={days}
|
||||
onDayClick={viewMode === 'week' ? (day) => { setViewDate(day); setViewMode('day'); } : undefined}
|
||||
/>
|
||||
)}
|
||||
{layout.appointments.map(apt => {
|
||||
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
|
||||
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}>
|
||||
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
|
||||
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0">•</span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
|
||||
</div>);
|
||||
|
||||
555
frontend/src/pages/TimeBlocks.tsx
Normal file
555
frontend/src/pages/TimeBlocks.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Time Blocks Management Page
|
||||
*
|
||||
* Allows business owners/managers to manage time blocks for business closures,
|
||||
* holidays, and resource-specific unavailability.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
TimeBlockListItem,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
} from '../types';
|
||||
import {
|
||||
useTimeBlocks,
|
||||
useCreateTimeBlock,
|
||||
useUpdateTimeBlock,
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import Portal from '../components/Portal';
|
||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
|
||||
import {
|
||||
Building2,
|
||||
User,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
Ban,
|
||||
AlertCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
||||
|
||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||
NONE: 'One-time',
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
YEARLY: 'Yearly',
|
||||
HOLIDAY: 'Holiday',
|
||||
};
|
||||
|
||||
const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
HARD: 'Hard Block',
|
||||
SOFT: 'Soft Block',
|
||||
};
|
||||
|
||||
const TimeBlocks: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TimeBlockTab>('business');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Fetch data (include inactive blocks so users can re-enable them)
|
||||
const {
|
||||
data: businessBlocks = [],
|
||||
isLoading: businessLoading,
|
||||
} = useTimeBlocks({ level: 'business' });
|
||||
|
||||
const {
|
||||
data: resourceBlocks = [],
|
||||
isLoading: resourceLoading,
|
||||
} = useTimeBlocks({ level: 'resource' });
|
||||
|
||||
const { data: holidays = [] } = useHolidays('US');
|
||||
const { data: resources = [] } = useResources();
|
||||
|
||||
// Mutations
|
||||
const createBlock = useCreateTimeBlock();
|
||||
const updateBlock = useUpdateTimeBlock();
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
|
||||
// Current blocks based on tab
|
||||
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
|
||||
const isLoading = activeTab === 'business' ? businessLoading : resourceLoading;
|
||||
|
||||
// Group resource blocks by resource
|
||||
const resourceBlocksGrouped = useMemo(() => {
|
||||
const groups: Record<string, TimeBlockListItem[]> = {};
|
||||
resourceBlocks.forEach((block) => {
|
||||
const resourceId = block.resource || 'unassigned';
|
||||
if (!groups[resourceId]) groups[resourceId] = [];
|
||||
groups[resourceId].push(block);
|
||||
});
|
||||
return groups;
|
||||
}, [resourceBlocks]);
|
||||
|
||||
// Modal handlers
|
||||
const openCreateModal = () => {
|
||||
setEditingBlock(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (block: TimeBlockListItem) => {
|
||||
setEditingBlock(block);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBlock(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteBlock.mutateAsync(id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
try {
|
||||
await toggleBlock.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render block type badge
|
||||
const renderBlockTypeBadge = (type: BlockType) => (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
type === 'HARD'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{type === 'HARD' ? <Ban size={12} className="mr-1" /> : <AlertCircle size={12} className="mr-1" />}
|
||||
{BLOCK_TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Render recurrence type badge
|
||||
const renderRecurrenceBadge = (type: RecurrenceType) => (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{type === 'HOLIDAY' ? (
|
||||
<CalendarDays size={12} className="mr-1" />
|
||||
) : type === 'NONE' ? (
|
||||
<Clock size={12} className="mr-1" />
|
||||
) : (
|
||||
<Calendar size={12} className="mr-1" />
|
||||
)}
|
||||
{RECURRENCE_TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.title', 'Time Blocks')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('timeBlocks.subtitle', 'Manage business closures, holidays, and resource unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('timeBlocks.addBlock', 'Add Block')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Time block tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('business')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'business'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Building2 size={18} />
|
||||
{t('timeBlocks.businessTab', 'Business Blocks')}
|
||||
{businessBlocks.length > 0 && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{businessBlocks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('resource')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'resource'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<User size={18} />
|
||||
{t('timeBlocks.resourceTab', 'Resource Blocks')}
|
||||
{resourceBlocks.length > 0 && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{resourceBlocks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'calendar'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
{t('timeBlocks.calendarTab', 'Yearly View')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activeTab === 'business' && (
|
||||
<>
|
||||
{/* Business Blocks Info */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<Building2 size={16} className="inline mr-2" />
|
||||
{t('timeBlocks.businessInfo', 'Business blocks apply to all resources. Use these for holidays, company closures, and business-wide events.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business Blocks List */}
|
||||
{businessBlocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<CalendarDays size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('timeBlocks.noBusinessBlocks', 'No Business Blocks')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('timeBlocks.noBusinessBlocksDesc', 'Add holidays and business closures to prevent bookings during those times.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('timeBlocks.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('timeBlocks.titleCol', 'Title')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('timeBlocks.typeCol', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('timeBlocks.patternCol', 'Pattern')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('timeBlocks.actionsCol', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{businessBlocks.map((block) => (
|
||||
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
{!block.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(block)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'resource' && (
|
||||
<>
|
||||
{/* Resource Blocks Info */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-300">
|
||||
<User size={16} className="inline mr-2" />
|
||||
{t('timeBlocks.resourceInfo', 'Resource blocks apply to specific staff or equipment. Use these for vacations, maintenance, or personal time.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Resource Blocks List */}
|
||||
{resourceBlocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<User size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('timeBlocks.noResourceBlocks', 'No Resource Blocks')}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('timeBlocks.noResourceBlocksDesc', 'Add time blocks for specific resources to manage their availability.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('timeBlocks.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{resources.map((resource) => {
|
||||
const blocks = resourceBlocksGrouped[resource.id] || [];
|
||||
if (blocks.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<User size={16} />
|
||||
{resource.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Block
|
||||
</button>
|
||||
</div>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{blocks.map((block) => (
|
||||
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
{!block.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(block)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(block.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'calendar' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<YearlyBlockCalendar
|
||||
onBlockClick={(blockId) => {
|
||||
// Find the block and open edit modal
|
||||
const block = [...businessBlocks, ...resourceBlocks].find(b => b.id === blockId);
|
||||
if (block) {
|
||||
openEditModal(block);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<TimeBlockCreatorModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
|
||||
} else {
|
||||
// Handle array of blocks (multiple holidays)
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
for (const block of blocks) {
|
||||
await createBlock.mutateAsync(block);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
}}
|
||||
isSubmitting={createBlock.isPending || updateBlock.isPending}
|
||||
editingBlock={editingBlock}
|
||||
holidays={holidays}
|
||||
resources={resources}
|
||||
isResourceLevel={activeTab === 'resource'}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
|
||||
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.deleteConfirmTitle', 'Delete Time Block?')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('timeBlocks.deleteConfirmDesc', 'This action cannot be undone.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
disabled={deleteBlock.isPending}
|
||||
>
|
||||
{deleteBlock.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.deleting', 'Deleting...')}
|
||||
</span>
|
||||
) : (
|
||||
t('common.delete', 'Delete')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBlocks;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Clock, Eye, Palette, Link2, Mail, Globe, CreditCard, Zap, Search, Filter,
|
||||
Plus, Edit, Trash2, ArrowUpDown, GripVertical, Image, Save, ExternalLink,
|
||||
MessageSquare, Tag, UserPlus, Shield, Copy, Layers, Play, Pause, Puzzle,
|
||||
FileSignature, Send, Download, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface TocSubItem {
|
||||
@@ -52,6 +53,7 @@ const HelpComprehensive: React.FC = () => {
|
||||
{ id: 'customers', label: 'Customers', icon: <Users size={16} /> },
|
||||
{ id: 'staff', label: 'Staff', icon: <UserCog size={16} /> },
|
||||
{ id: 'plugins', label: 'Plugins', icon: <Puzzle size={16} /> },
|
||||
{ id: 'contracts', label: 'Contracts', icon: <FileSignature size={16} /> },
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
@@ -703,6 +705,146 @@ const HelpComprehensive: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* CONTRACTS */}
|
||||
{/* ============================================== */}
|
||||
<section id="contracts" className="mb-16 scroll-mt-24">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<FileSignature size={20} className="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Contracts</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
The Contracts feature enables electronic document signing for your business. Create reusable
|
||||
templates, send contracts to customers, and maintain legally compliant audit trails with automatic
|
||||
PDF generation.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Templates</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
Templates are reusable contract documents with placeholder variables that get filled in when sent:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Template Properties</h4>
|
||||
<ul className="text-xs text-gray-500 dark:text-gray-400 mt-2 space-y-1">
|
||||
<li>• <strong>Name:</strong> Internal template identifier</li>
|
||||
<li>• <strong>Content:</strong> HTML document with variables</li>
|
||||
<li>• <strong>Scope:</strong> Customer-level or per-appointment</li>
|
||||
<li>• <strong>Expiration:</strong> Days until contract expires</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Available Variables</h4>
|
||||
<ul className="text-xs text-gray-500 dark:text-gray-400 mt-2 space-y-1">
|
||||
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{CUSTOMER_NAME}}'}</code></li>
|
||||
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{CUSTOMER_EMAIL}}'}</code></li>
|
||||
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{BUSINESS_NAME}}'}</code></li>
|
||||
<li>• <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{DATE}}'}</code> and <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{YEAR}}'}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Workflow</h3>
|
||||
<ol className="space-y-3 mb-6">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">1</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">Create Contract</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Select a template and customer. Variables are automatically filled in.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">2</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">Send for Signing</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Customer receives an email with a secure signing link.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">3</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">Customer Signs</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Customer agrees via checkbox consent with full audit trail capture.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center font-medium">4</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">PDF Generated</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Signed PDF with audit trail is generated and stored automatically.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Contract Statuses</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div className="p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Awaiting signature</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">Signed</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Successfully completed</p>
|
||||
</div>
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">Expired</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Past expiration date</p>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Voided</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Manually cancelled</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Legal Compliance</h3>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield size={20} className="text-emerald-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">ESIGN & UETA Compliant</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
All signatures capture: timestamp, IP address, user agent, document hash, consent checkbox states,
|
||||
and exact consent language. This creates a legally defensible audit trail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Key Features</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<Send size={16} className="text-emerald-500" />
|
||||
<span><strong>Email Delivery:</strong> Contracts are sent directly to customer email with signing link</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<LinkIcon size={16} className="text-emerald-500" />
|
||||
<span><strong>Shareable Links:</strong> Copy signing link to share via other channels</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Download size={16} className="text-emerald-500" />
|
||||
<span><strong>PDF Download:</strong> Download signed contracts with full audit trail</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Eye size={16} className="text-emerald-500" />
|
||||
<span><strong>Status Tracking:</strong> Monitor which contracts are pending, signed, or expired</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Link to="/help/contracts" onClick={scrollToTop} className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<FileSignature size={24} className="text-emerald-500" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Contracts Documentation</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Complete guide to templates, signing, and compliance features</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* SETTINGS */}
|
||||
{/* ============================================== */}
|
||||
|
||||
299
frontend/src/pages/help/HelpContracts.tsx
Normal file
299
frontend/src/pages/help/HelpContracts.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Help Contracts Page
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, FileSignature, FileText, Send, CheckCircle, ChevronRight,
|
||||
HelpCircle, Shield, Clock, Users, AlertCircle, Copy, Eye, Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
const HelpContracts: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6">
|
||||
<ArrowLeft size={20} /> Back
|
||||
</button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<FileSignature size={24} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Contracts Guide</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Create and manage digital contracts with e-signatures</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<FileSignature size={20} className="text-brand-500" /> Overview
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
The Contracts system allows you to create reusable contract templates, send them to customers for digital signature, and maintain legally compliant records with full audit trails.
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
All signatures are captured with ESIGN Act and UETA compliance, including IP address, timestamp, browser information, and optional geolocation for maximum legal protection.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<FileText size={20} className="text-brand-500" /> Contract Templates
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Templates are reusable contract documents that can be personalized with variable placeholders.
|
||||
</p>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Template Variables</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
Use these placeholders in your templates - they'll be automatically replaced when the contract is created:
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm font-mono">
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_NAME}}"}</span> - Full name</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_FIRST_NAME}}"}</span> - First name</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_EMAIL}}"}</span> - Email address</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{CUSTOMER_PHONE}}"}</span> - Phone number</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_NAME}}"}</span> - Your business name</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_EMAIL}}"}</span> - Contact email</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{BUSINESS_PHONE}}"}</span> - Business phone</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{DATE}}"}</span> - Current date</div>
|
||||
<div><span className="text-blue-600 dark:text-blue-400">{"{{YEAR}}"}</span> - Current year</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Template Scopes</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<Users size={20} className="text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Customer-Level</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">One-time contracts per customer (e.g., privacy policy, terms of service)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<Clock size={20} className="text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Appointment-Level</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Signed for each booking (e.g., liability waivers, service agreements)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Send size={20} className="text-brand-500" /> Sending Contracts
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Select a Template</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Choose from your active contract templates</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Choose a Customer</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Variables are automatically filled with customer data</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Send for Signature</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Customer receives an email with a secure signing link</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Track Status</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Monitor pending, signed, expired, or voided contracts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Eye size={20} className="text-brand-500" /> Contract Status
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Pending</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- Awaiting customer signature</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Signed</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- Customer has signed the contract</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Expired</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- Contract expired before signing</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Voided</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- Contract was cancelled by business</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Shield size={20} className="text-brand-500" /> Legal Compliance
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>ESIGN Act & UETA Compliant:</strong> All signatures include comprehensive audit trails that meet federal and state requirements for electronic signatures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Captured Audit Data</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Document hash (SHA-256)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Signature timestamp (ISO)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Signer's IP address</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Browser/device information</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Consent checkbox states</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Geolocation (if permitted)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Download size={20} className="text-brand-500" /> PDF Generation
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Once a contract is signed, a PDF is automatically generated that includes:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">The full contract content with substituted variables</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Signature section with signer's name and consent confirmations</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Complete audit trail footer with all verification data</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Your business branding and logo</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<CheckCircle size={20} className="text-brand-500" /> Best Practices
|
||||
</h2>
|
||||
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300"><strong>Use Clear Language:</strong> Write contracts in plain language that customers can easily understand</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300"><strong>Set Expiration Dates:</strong> Use the expiration feature to ensure contracts are signed in a timely manner</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300"><strong>Link to Services:</strong> Associate contracts with specific services for automatic requirement checking</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300"><strong>Version Control:</strong> Create new versions rather than editing existing active templates</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300"><strong>Download PDFs:</strong> Keep copies of signed contracts for your records</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Related Features</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link to="/help/services" className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-brand-500 transition-colors">
|
||||
<FileText size={20} className="text-brand-500" />
|
||||
<span className="text-gray-900 dark:text-white">Services Guide</span>
|
||||
<ChevronRight size={16} className="text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
<Link to="/help/customers" className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-brand-500 transition-colors">
|
||||
<Users size={20} className="text-brand-500" />
|
||||
<span className="text-gray-900 dark:text-white">Customers Guide</span>
|
||||
<ChevronRight size={16} className="text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Need More Help?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">Our support team is ready to help with any questions about contracts.</p>
|
||||
<button onClick={() => navigate('/tickets')} className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">Contact Support</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpContracts;
|
||||
@@ -448,4 +448,197 @@ export interface EmailTemplateVariable {
|
||||
export interface EmailTemplateVariableGroup {
|
||||
category: string;
|
||||
items: EmailTemplateVariable[];
|
||||
}
|
||||
|
||||
// --- Contract Types ---
|
||||
|
||||
export type ContractScope = 'CUSTOMER' | 'APPOINTMENT';
|
||||
export type ContractStatus = 'PENDING' | 'SIGNED' | 'EXPIRED' | 'VOIDED';
|
||||
export type ContractTemplateStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
scope: ContractScope;
|
||||
status: ContractTemplateStatus;
|
||||
expires_after_days: number | null;
|
||||
version: number;
|
||||
version_notes: string;
|
||||
services: { id: string; name: string }[];
|
||||
created_by: string | null;
|
||||
created_by_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Contract {
|
||||
id: string;
|
||||
template: string;
|
||||
template_name: string;
|
||||
template_version: number;
|
||||
scope: ContractScope;
|
||||
status: ContractStatus;
|
||||
content: string;
|
||||
customer?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
appointment?: string;
|
||||
appointment_service_name?: string;
|
||||
appointment_start_time?: string;
|
||||
service?: string;
|
||||
service_name?: string;
|
||||
sent_at: string | null;
|
||||
signed_at: string | null;
|
||||
expires_at: string | null;
|
||||
voided_at: string | null;
|
||||
voided_reason: string | null;
|
||||
public_token: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContractSignature {
|
||||
id: string;
|
||||
contract: string;
|
||||
signer_name: string;
|
||||
signer_email: string;
|
||||
signature_data: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
signed_at: string;
|
||||
}
|
||||
|
||||
export interface ContractPublicView {
|
||||
contract: Contract;
|
||||
template: {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
business: {
|
||||
name: string;
|
||||
logo_url?: string;
|
||||
};
|
||||
customer?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
appointment?: {
|
||||
service_name: string;
|
||||
start_time: string;
|
||||
};
|
||||
is_expired: boolean;
|
||||
can_sign: boolean;
|
||||
signature?: ContractSignature;
|
||||
}
|
||||
|
||||
// --- Time Blocking Types ---
|
||||
|
||||
export type BlockType = 'HARD' | 'SOFT';
|
||||
export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
|
||||
export type TimeBlockLevel = 'business' | 'resource';
|
||||
|
||||
export type HolidayType = 'FIXED' | 'FLOATING' | 'CALCULATED';
|
||||
|
||||
export interface Holiday {
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
holiday_type?: HolidayType;
|
||||
month?: number;
|
||||
day?: number;
|
||||
week_of_month?: number;
|
||||
day_of_week?: number;
|
||||
calculation_rule?: string;
|
||||
is_active?: boolean;
|
||||
next_occurrence?: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface RecurrencePattern {
|
||||
days_of_week?: number[]; // 0=Mon, 6=Sun (for WEEKLY)
|
||||
days_of_month?: number[]; // 1-31 (for MONTHLY)
|
||||
month?: number; // 1-12 (for YEARLY)
|
||||
day?: number; // 1-31 (for YEARLY)
|
||||
holiday_code?: string; // holiday code (for HOLIDAY)
|
||||
}
|
||||
|
||||
export interface TimeBlock {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string | null; // Resource ID or null for business-level
|
||||
resource_name?: string;
|
||||
level: TimeBlockLevel;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date?: string; // ISO date string (for NONE type)
|
||||
end_date?: string; // ISO date string (for NONE type)
|
||||
all_day: boolean;
|
||||
start_time?: string; // HH:MM:SS (if not all_day)
|
||||
end_time?: string; // HH:MM:SS (if not all_day)
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
pattern_display?: string; // Human-readable pattern description
|
||||
holiday_name?: string; // Holiday name if HOLIDAY type
|
||||
recurrence_start?: string; // ISO date string
|
||||
recurrence_end?: string; // ISO date string
|
||||
is_active: boolean;
|
||||
created_by?: string;
|
||||
created_by_name?: string;
|
||||
conflict_count?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface TimeBlockListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string | null;
|
||||
resource_name?: string;
|
||||
level: TimeBlockLevel;
|
||||
block_type: BlockType;
|
||||
recurrence_type: RecurrenceType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
all_day?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
recurrence_pattern?: RecurrencePattern;
|
||||
recurrence_start?: string;
|
||||
recurrence_end?: string;
|
||||
pattern_display?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BlockedDate {
|
||||
date: string; // ISO date string
|
||||
block_type: BlockType;
|
||||
title: string;
|
||||
resource_id: string | null;
|
||||
all_day: boolean;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
time_block_id: string;
|
||||
}
|
||||
|
||||
export interface TimeBlockConflict {
|
||||
event_id: string;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
export interface TimeBlockConflictCheck {
|
||||
has_conflicts: boolean;
|
||||
conflict_count: number;
|
||||
conflicts: TimeBlockConflict[];
|
||||
}
|
||||
|
||||
export interface MyBlocksResponse {
|
||||
business_blocks: TimeBlockListItem[];
|
||||
my_blocks: TimeBlockListItem[];
|
||||
resource_id: string;
|
||||
resource_name: string;
|
||||
}
|
||||
Reference in New Issue
Block a user