diff --git a/src/components/ui/NEW_COMPONENTS.md b/src/components/ui/NEW_COMPONENTS.md new file mode 100644 index 0000000..0acfa14 --- /dev/null +++ b/src/components/ui/NEW_COMPONENTS.md @@ -0,0 +1,390 @@ +# New UI Components + +This document describes the newly added UI components for WorkForce Pro platform. + +## Display Components + +### `` +Vertical timeline for displaying chronological events. + +```tsx +import { Timeline, TimelineItemProps } from '@/components/ui/timeline-vertical' + +const items: TimelineItemProps[] = [ + { + date: '2024-01-15', + title: 'Timesheet Submitted', + description: 'Worker submitted timesheet for week ending 14 Jan', + status: 'completed', + icon: + }, + { + date: '2024-01-16', + title: 'Manager Approval', + description: 'Awaiting manager approval', + status: 'current' + }, + { + date: '2024-01-17', + title: 'Invoice Generation', + description: 'Invoice will be generated after approval', + status: 'upcoming' + } +] + + +``` + +### `` +Display key-value pairs in various layouts. + +```tsx +import { DescriptionList, DataValue } from '@/components/ui/description-list' + +const items: DataValue[] = [ + { label: 'Worker Name', value: 'John Smith' }, + { label: 'Total Hours', value: '40.0', badge: Full Time }, + { label: 'Rate', value: '£25.00/hr' }, + { label: 'Total Amount', value: '£1,000.00' } +] + + + +``` + +### `` +Visual status indicators with optional pulse animation. + +```tsx +import { StatusIndicator } from '@/components/ui/status-indicator' + + + + +``` + +**Status types:** `success`, `warning`, `error`, `info`, `default`, `pending`, `approved`, `rejected` + +### `` +Display chronological activity feed with user actions. + +```tsx +import { ActivityLog, ActivityLogEntry } from '@/components/ui/activity-log' + +const entries: ActivityLogEntry[] = [ + { + id: '1', + timestamp: '2 hours ago', + user: { name: 'John Smith', avatar: '/avatars/john.jpg' }, + action: 'updated', + description: 'Changed timesheet hours from 40 to 45', + icon: , + metadata: { + 'Previous Hours': '40', + 'New Hours': '45', + 'Reason': 'Client requested adjustment' + } + } +] + + +``` + +### `` +Styled notification list with actions and dismissal. + +```tsx +import { NotificationList, NotificationItem } from '@/components/ui/notification-list' + +const notifications: NotificationItem[] = [ + { + id: '1', + title: 'Timesheet Approved', + message: 'Your timesheet for week ending 14 Jan has been approved', + type: 'success', + timestamp: '5 minutes ago', + read: false, + action: { + label: 'View Details', + onClick: () => navigate('/timesheets/123') + } + } +] + + removeNotification(id)} + onNotificationClick={(notification) => markAsRead(notification.id)} +/> +``` + +## Navigation & Flow Components + +### `` +Multi-step wizard with validation and progress tracking. + +```tsx +import { Wizard, WizardStep } from '@/components/ui/wizard' + +const steps: WizardStep[] = [ + { + id: 'details', + title: 'Basic Details', + description: 'Worker and client information', + content: , + validate: async () => { + // Return true if valid, false otherwise + return form.workerName && form.clientName + } + }, + { + id: 'hours', + title: 'Hours', + description: 'Enter working hours', + content: + }, + { + id: 'review', + title: 'Review', + description: 'Review and submit', + content: + } +] + + submitTimesheet(data)} + onCancel={() => navigate('/timesheets')} + showStepIndicator +/> +``` + +### `` +Progress stepper for showing current position in a flow. + +```tsx +import { Stepper, Step } from '@/components/ui/stepper-simple' + +const steps: Step[] = [ + { label: 'Submit', description: 'Worker submission', status: 'completed' }, + { label: 'Approve', description: 'Manager approval', status: 'current' }, + { label: 'Invoice', description: 'Generate invoice', status: 'upcoming' } +] + + + +``` + +## Data Display Components + +### `` +Lightweight table with custom rendering. + +```tsx +import { SimpleTable, DataTableColumn } from '@/components/ui/simple-table' + +const columns: DataTableColumn[] = [ + { key: 'workerName', label: 'Worker', width: '200px' }, + { key: 'hours', label: 'Hours', align: 'right' }, + { + key: 'amount', + label: 'Amount', + align: 'right', + render: (value) => formatCurrency(value) + }, + { + key: 'status', + label: 'Status', + render: (value) => {value} + } +] + + navigate(`/timesheets/${row.id}`)} + striped + hoverable +/> +``` + +## Input & Action Components + +### `` +Full-featured pagination with page numbers. + +```tsx +import { PaginationButtons } from '@/components/ui/pagination-buttons' + + setCurrentPage(page)} + showFirstLast + maxButtons={7} +/> +``` + +### `` +Multi-format export button with dropdown. + +```tsx +import { ExportButton } from '@/components/ui/export-button' + + { + if (format === 'csv') exportToCSV(data) + if (format === 'json') exportToJSON(data) + }} + formats={['csv', 'json', 'xlsx']} + variant="outline" +/> + +// Single format (no dropdown) + exportToCSV(data)} + formats={['csv']} +/> +``` + +### `` +Display active filters as removable chips. + +```tsx +import { FilterChipsBar, FilterChip } from '@/components/ui/filter-chips-bar' + +const filters: FilterChip[] = [ + { id: '1', label: 'Pending', value: 'pending', field: 'Status' }, + { id: '2', label: 'This Week', value: 'week', field: 'Date Range' }, + { id: '3', label: 'John Smith', value: 'john', field: 'Worker' } +] + + removeFilter(id)} + onClearAll={() => clearAllFilters()} + showClearAll +/> +``` + +### `` +Debounced search input with icon. + +```tsx +import { QuickSearch } from '@/components/ui/quick-search' + + setSearchQuery(value)} + debounceMs={300} + placeholder="Search timesheets..." +/> +``` + +## Complete Examples + +### Timesheet List with Filters and Export +```tsx +import { SimpleTable, FilterChipsBar, ExportButton, QuickSearch } from '@/components/ui' +import { useFilterableData, useSortableData, useDataExport } from '@/hooks' + +function TimesheetList() { + const { filteredData, filters, addFilter, removeFilter, clearFilters } = useFilterableData(timesheets) + const { sortedData, requestSort } = useSortableData(filteredData) + const { exportToCSV } = useDataExport() + + return ( +
+
+ addFilter({ + field: 'workerName', + operator: 'contains', + value: q + })} /> + exportToCSV(sortedData, { filename: 'timesheets' })} + formats={['csv', 'json']} + /> +
+ + + + openDetails(row)} + /> +
+ ) +} +``` + +### Multi-Step Approval Workflow +```tsx +import { Wizard, Stepper, ActivityLog } from '@/components/ui' +import { useApprovalWorkflow, useAuditLog } from '@/hooks' + +function ApprovalWorkflowView() { + const { workflows, approveStep } = useApprovalWorkflow() + const { auditLog, getLogsByEntity } = useAuditLog() + + const workflow = workflows[0] + const logs = getLogsByEntity('workflow', workflow.id) + + return ( +
+ ({ + label: s.approverRole, + status: s.status + }))} + currentStep={workflow.currentStepIndex} + /> + + + + +
+ ) +} +``` + +### Status Dashboard with Timeline +```tsx +import { Timeline, StatusIndicator, DescriptionList } from '@/components/ui' + +function TimesheetStatus({ timesheet }) { + const timeline = [ + { + date: timesheet.submittedDate, + title: 'Submitted', + status: 'completed', + description: `Submitted by ${timesheet.workerName}` + }, + { + date: timesheet.approvedDate, + title: 'Approved', + status: timesheet.status === 'approved' ? 'completed' : 'current', + description: 'Awaiting manager approval' + } + ] + + const details = [ + { label: 'Status', value: }, + { label: 'Hours', value: timesheet.hours }, + { label: 'Amount', value: formatCurrency(timesheet.amount) } + ] + + return ( +
+ + +
+ ) +} +``` diff --git a/src/components/ui/activity-log.tsx b/src/components/ui/activity-log.tsx new file mode 100644 index 0000000..d34a761 --- /dev/null +++ b/src/components/ui/activity-log.tsx @@ -0,0 +1,88 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface ActivityLogEntry { + id: string + timestamp: string + user?: { + name: string + avatar?: string + } + action: string + description: string + icon?: React.ReactNode + metadata?: Record +} + +export interface ActivityLogProps extends React.HTMLAttributes { + entries: ActivityLogEntry[] + emptyMessage?: string +} + +const ActivityLog = React.forwardRef( + ({ entries, emptyMessage = 'No activity yet', className, ...props }, ref) => { + if (entries.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ {entries.map((entry, index) => ( +
+ {index !== entries.length - 1 && ( +
+ )} + +
+ {entry.icon || ( +
+ )} +
+ +
+
+ {entry.user && ( + {entry.user.name} + )} + {entry.action} + +
+ +

{entry.description}

+ + {entry.metadata && Object.keys(entry.metadata).length > 0 && ( +
+ {Object.entries(entry.metadata).map(([key, value]) => ( +
+ {key}: + {String(value)} +
+ ))} +
+ )} +
+
+ ))} +
+ ) + } +) +ActivityLog.displayName = 'ActivityLog' + +export { ActivityLog } diff --git a/src/components/ui/description-list.tsx b/src/components/ui/description-list.tsx new file mode 100644 index 0000000..bbbc9c1 --- /dev/null +++ b/src/components/ui/description-list.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface DataValue { + label: string + value: string | number | React.ReactNode + badge?: React.ReactNode +} + +export interface DescriptionListProps extends React.HTMLAttributes { + items: DataValue[] + layout?: 'vertical' | 'horizontal' | 'grid' + columns?: 1 | 2 | 3 | 4 +} + +const DescriptionList = React.forwardRef( + ({ items, layout = 'horizontal', columns = 2, className, ...props }, ref) => { + const layoutStyles = { + vertical: 'flex flex-col gap-4', + horizontal: 'grid gap-4', + grid: `grid gap-4 grid-cols-1 md:grid-cols-${columns}`, + } + + return ( +
+ {items.map((item, index) => ( +
+
+ {item.label} + {item.badge} +
+
+ {item.value} +
+
+ ))} +
+ ) + } +) +DescriptionList.displayName = 'DescriptionList' + +export { DescriptionList } diff --git a/src/components/ui/export-button.tsx b/src/components/ui/export-button.tsx new file mode 100644 index 0000000..b4830d6 --- /dev/null +++ b/src/components/ui/export-button.tsx @@ -0,0 +1,107 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { Button } from './button' +import { Download, FileText, Table } from '@phosphor-icons/react' + +export interface ExportButtonProps extends React.ButtonHTMLAttributes { + onExport: (format: 'csv' | 'json' | 'xlsx') => void + formats?: Array<'csv' | 'json' | 'xlsx'> + variant?: 'default' | 'outline' | 'ghost' + size?: 'default' | 'sm' | 'lg' +} + +const ExportButton = React.forwardRef( + ( + { + onExport, + formats = ['csv', 'json'], + variant = 'outline', + size = 'default', + className, + ...props + }, + ref + ) => { + const [isOpen, setIsOpen] = React.useState(false) + + const handleExport = (format: 'csv' | 'json' | 'xlsx') => { + onExport(format) + setIsOpen(false) + } + + if (formats.length === 1) { + return ( + + ) + } + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+ {formats.includes('csv') && ( +
+ ) +} + +export const SimpleTable = React.forwardRef(SimpleTableInner) as ( + props: SimpleTableProps & { ref?: React.ForwardedRef } +) => React.ReactElement diff --git a/src/components/ui/status-indicator.tsx b/src/components/ui/status-indicator.tsx new file mode 100644 index 0000000..2f9249a --- /dev/null +++ b/src/components/ui/status-indicator.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export type StatusType = + | 'success' + | 'warning' + | 'error' + | 'info' + | 'default' + | 'pending' + | 'approved' + | 'rejected' + +export interface StatusIndicatorProps extends React.HTMLAttributes { + status: StatusType + label?: string + pulse?: boolean + size?: 'sm' | 'md' | 'lg' +} + +const statusStyles: Record = { + success: 'bg-success', + warning: 'bg-warning', + error: 'bg-destructive', + info: 'bg-info', + default: 'bg-muted-foreground', + pending: 'bg-warning', + approved: 'bg-success', + rejected: 'bg-destructive', +} + +const sizeStyles = { + sm: 'h-2 w-2', + md: 'h-3 w-3', + lg: 'h-4 w-4', +} + +const StatusIndicator = React.forwardRef( + ({ status, label, pulse = false, size = 'md', className, ...props }, ref) => { + return ( +
+
+
+ {pulse && ( +
+ )} +
+ {label && {label}} +
+ ) + } +) +StatusIndicator.displayName = 'StatusIndicator' + +export { StatusIndicator } diff --git a/src/components/ui/stepper-simple.tsx b/src/components/ui/stepper-simple.tsx new file mode 100644 index 0000000..92831c3 --- /dev/null +++ b/src/components/ui/stepper-simple.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface Step { + label: string + description?: string + status?: 'completed' | 'current' | 'upcoming' | 'error' + icon?: React.ReactNode +} + +export interface StepperProps extends React.HTMLAttributes { + steps: Step[] + orientation?: 'horizontal' | 'vertical' + currentStep?: number +} + +const Stepper = React.forwardRef( + ( + { steps, orientation = 'horizontal', currentStep = 0, className, ...props }, + ref + ) => { + return ( +
+ {steps.map((step, index) => { + const status = + step.status || + (index < currentStep + ? 'completed' + : index === currentStep + ? 'current' + : 'upcoming') + + const isLast = index === steps.length - 1 + + return ( + +
+
+
+ {step.icon || (status === 'completed' ? '✓' : index + 1)} +
+
+
+ {step.label} +
+ {step.description && ( +
+ {step.description} +
+ )} +
+
+
+ {!isLast && ( +
+ )} + + ) + })} +
+ ) + } +) +Stepper.displayName = 'Stepper' + +export { Stepper } diff --git a/src/components/ui/timeline-vertical.tsx b/src/components/ui/timeline-vertical.tsx new file mode 100644 index 0000000..66dcac5 --- /dev/null +++ b/src/components/ui/timeline-vertical.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface TimelineItemProps { + date: string + title: string + description?: string + icon?: React.ReactNode + status?: 'completed' | 'current' | 'upcoming' + children?: React.ReactNode +} + +export interface TimelineProps { + items: TimelineItemProps[] + className?: string +} + +const Timeline = React.forwardRef( + ({ items, className }, ref) => { + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ) + } +) +Timeline.displayName = 'Timeline' + +const TimelineItem = React.forwardRef< + HTMLDivElement, + TimelineItemProps & { isLast?: boolean } +>(({ date, title, description, icon, status = 'upcoming', children, isLast }, ref) => { + const statusStyles = { + completed: 'bg-success border-success', + current: 'bg-accent border-accent', + upcoming: 'bg-muted border-border', + } + + const lineStyles = { + completed: 'bg-success', + current: 'bg-accent', + upcoming: 'bg-border', + } + + return ( +
+
+
+ {icon || ( +
+ )} +
+ {!isLast && ( +
+ )} +
+ +
+
+ +
+

{title}

+ {description && ( +

{description}

+ )} + {children &&
{children}
} +
+
+ ) +}) +TimelineItem.displayName = 'TimelineItem' + +export { Timeline, TimelineItem } diff --git a/src/components/ui/wizard.tsx b/src/components/ui/wizard.tsx new file mode 100644 index 0000000..44ea9c7 --- /dev/null +++ b/src/components/ui/wizard.tsx @@ -0,0 +1,162 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { Button } from './button' +import { CaretLeft, CaretRight } from '@phosphor-icons/react' + +export interface WizardStep { + id: string + title: string + description?: string + content: React.ReactNode + validate?: () => boolean | Promise +} + +export interface WizardProps { + steps: WizardStep[] + onComplete?: (data: any) => void + onCancel?: () => void + className?: string + showStepIndicator?: boolean +} + +const Wizard = React.forwardRef( + ( + { + steps, + onComplete, + onCancel, + className, + showStepIndicator = true, + }, + ref + ) => { + const [currentStepIndex, setCurrentStepIndex] = React.useState(0) + const [completedSteps, setCompletedSteps] = React.useState>( + new Set() + ) + + const currentStep = steps[currentStepIndex] + const isFirstStep = currentStepIndex === 0 + const isLastStep = currentStepIndex === steps.length - 1 + + const handleNext = async () => { + if (currentStep.validate) { + const isValid = await currentStep.validate() + if (!isValid) return + } + + setCompletedSteps((prev) => new Set(prev).add(currentStepIndex)) + + if (isLastStep) { + onComplete?.({}) + } else { + setCurrentStepIndex((prev) => prev + 1) + } + } + + const handleBack = () => { + if (!isFirstStep) { + setCurrentStepIndex((prev) => prev - 1) + } + } + + const handleStepClick = (index: number) => { + if (index < currentStepIndex || completedSteps.has(index)) { + setCurrentStepIndex(index) + } + } + + return ( +
+ {showStepIndicator && ( +
+ {steps.map((step, index) => { + const isCompleted = completedSteps.has(index) + const isCurrent = index === currentStepIndex + const isAccessible = index <= currentStepIndex || isCompleted + + return ( + + + {index < steps.length - 1 && ( +
+ )} + + ) + })} +
+ )} + +
{currentStep.content}
+ +
+
+ {!isFirstStep && ( + + )} + {isFirstStep && onCancel && ( + + )} +
+ +
+ Step {currentStepIndex + 1} of {steps.length} +
+ + +
+
+ ) + } +) +Wizard.displayName = 'Wizard' + +export { Wizard } diff --git a/src/hooks/NEW_HOOKS.md b/src/hooks/NEW_HOOKS.md new file mode 100644 index 0000000..4d9a137 --- /dev/null +++ b/src/hooks/NEW_HOOKS.md @@ -0,0 +1,285 @@ +# New Custom Hooks + +This document describes the newly added custom hooks for WorkForce Pro platform. + +## Business Logic Hooks + +### `useRateCalculator()` +Calculate rates with premiums for overtime, night shifts, weekends, and holidays. + +```tsx +const { calculateRate, calculateTotalAmount, calculateMargin } = useRateCalculator() + +const breakdown = calculateRate({ + baseRate: 25, + hours: 8, + isNightShift: true, + isWeekend: true, + nightShiftMultiplier: 1.25, + weekendMultiplier: 1.5 +}) +// Returns: { baseRate, overtimeRate, nightShiftPremium, weekendPremium, holidayPremium, totalRate } + +const totalAmount = calculateTotalAmount({ baseRate: 25, hours: 40 }) +const margin = calculateMargin(chargeRate, payRate) +``` + +### `useAuditLog()` +Track all user actions with full audit trail capability. + +```tsx +const { auditLog, logAction, getLogsByEntity, getLogsByUser, clearLog } = useAuditLog() + +await logAction('UPDATE', 'timesheet', 'TS-123', + { hours: { old: 40, new: 45 } }, + { reason: 'Client requested adjustment' } +) + +const timesheetLogs = getLogsByEntity('timesheet', 'TS-123') +const userActions = getLogsByUser('user-id') +``` + +### `useRecurringSchedule()` +Manage recurring work schedules with patterns and time slots. + +```tsx +const { schedules, addSchedule, generateInstances, getScheduleForDate } = useRecurringSchedule() + +const schedule = addSchedule({ + name: 'Night Shift Pattern', + pattern: 'weekly', + startDate: '2024-01-01', + daysOfWeek: [1, 2, 3, 4, 5], // Mon-Fri + timeSlots: [ + { startTime: '22:00', endTime: '06:00', description: 'Night shift' } + ] +}) + +const instances = generateInstances(schedule, startDate, endDate) +const todaySchedules = getScheduleForDate(new Date()) +``` + +### `useComplianceCheck()` +Run compliance validation rules against data. + +```tsx +const { + defaultRules, + runCheck, + runAllChecks, + getFailedChecks, + hasCriticalFailures +} = useComplianceCheck() + +const checks = runAllChecks(defaultRules, { + expiryDate: '2024-12-31', + rate: 45, + weeklyHours: 42 +}) + +const failed = getFailedChecks(checks) +const critical = hasCriticalFailures(checks) +``` + +### `useApprovalWorkflow()` +Multi-step approval workflows for entities. + +```tsx +const { + workflows, + createWorkflow, + approveStep, + rejectStep, + getCurrentStep +} = useApprovalWorkflow() + +const workflow = createWorkflow('timesheet', 'TS-123', ['Manager', 'Director', 'Finance']) + +await approveStep(workflow.id, stepId, 'Approved - looks good') +await rejectStep(workflow.id, stepId, 'Hours exceed contract limit') + +const currentStep = getCurrentStep(workflow) +``` + +### `useDataExport()` +Export data to various formats with customization. + +```tsx +const { exportToCSV, exportToJSON, exportData } = useDataExport() + +exportToCSV(timesheets, { + filename: 'timesheets-export', + columns: ['workerName', 'hours', 'amount'], + includeHeaders: true +}) + +exportToJSON(invoices, { filename: 'invoices-2024' }) + +exportData(payrollRuns, { format: 'csv', filename: 'payroll' }) +``` + +## Data Management Hooks + +### `useHistory(initialState, maxHistory?)` +Undo/redo functionality with state history. + +```tsx +const { state, setState, undo, redo, canUndo, canRedo, clear } = useHistory(initialForm, 50) + +setState({ ...state, hours: 45 }) // Creates history entry +undo() // Reverts to previous state +redo() // Moves forward +``` + +### `useSortableData(data, defaultConfig?)` +Sort data by any field with direction control. + +```tsx +const { sortedData, sortConfig, requestSort, clearSort } = useSortableData(invoices, { + key: 'dueDate', + direction: 'desc' +}) + +requestSort('amount') // Toggle sort on amount field +clearSort() +``` + +### `useFilterableData(data)` +Advanced filtering with multiple operators. + +```tsx +const { filteredData, filters, addFilter, removeFilter, clearFilters } = useFilterableData(timesheets) + +addFilter({ field: 'status', operator: 'equals', value: 'pending' }) +addFilter({ field: 'hours', operator: 'greaterThan', value: 40 }) +addFilter({ field: 'workerName', operator: 'contains', value: 'John' }) + +removeFilter(0) // Remove first filter +clearFilters() +``` + +**Operators:** `equals`, `notEquals`, `contains`, `notContains`, `startsWith`, `endsWith`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `in`, `notIn` + +### `useFormatter(defaultOptions?)` +Format numbers, currency, dates, and percentages. + +```tsx +const { + formatCurrency, + formatNumber, + formatPercent, + formatDate, + formatTime, + formatDateTime, + formatValue +} = useFormatter({ locale: 'en-GB', currency: 'GBP' }) + +formatCurrency(1250.50) // "£1,250.50" +formatNumber(1234.567, { decimals: 2 }) // "1,234.57" +formatPercent(15.5) // "15.5%" +formatDate(new Date(), { dateFormat: 'dd MMM yyyy' }) // "15 Jan 2024" +formatValue(1500, 'currency') // "£1,500.00" +``` + +### `useTemplateManager(storageKey)` +Create and manage reusable templates. + +```tsx +const { + templates, + createTemplate, + updateTemplate, + deleteTemplate, + getTemplate, + duplicateTemplate, + applyTemplate +} = useTemplateManager('invoice-templates') + +const template = createTemplate('Standard Invoice', { + terms: 'Net 30', + footer: 'Thank you for your business' +}, 'Standard template for all clients', 'Default') + +const content = applyTemplate(template.id) +duplicateTemplate(template.id) +``` + +## Usage Examples + +### Complete Rate Calculation Flow +```tsx +import { useRateCalculator, useComplianceCheck } from '@/hooks' + +function TimesheetProcessor() { + const { calculateTotalAmount, calculateMargin } = useRateCalculator() + const { runAllChecks, defaultRules } = useComplianceCheck() + + const processTimesheet = (timesheet) => { + // Calculate amounts + const amount = calculateTotalAmount({ + baseRate: timesheet.rate, + hours: timesheet.hours, + isOvertime: timesheet.hours > 40 + }) + + // Run compliance checks + const checks = runAllChecks(defaultRules, { + weeklyHours: timesheet.hours, + rate: timesheet.rate + }) + + // Check margin + const margin = calculateMargin(timesheet.chargeRate, timesheet.payRate) + + return { amount, checks, margin } + } + + return
...
+} +``` + +### Approval Workflow with Audit Trail +```tsx +import { useApprovalWorkflow, useAuditLog } from '@/hooks' + +function ApprovalManager() { + const { createWorkflow, approveStep } = useApprovalWorkflow() + const { logAction } = useAuditLog() + + const submitForApproval = async (timesheetId) => { + const workflow = createWorkflow('timesheet', timesheetId, ['Manager', 'Finance']) + + await logAction('SUBMIT_APPROVAL', 'timesheet', timesheetId, undefined, { + workflowId: workflow.id + }) + } + + const approve = async (workflowId, stepId) => { + await approveStep(workflowId, stepId, 'Approved') + await logAction('APPROVE', 'workflow', workflowId) + } + + return
...
+} +``` + +### Data Export with Filtering +```tsx +import { useFilterableData, useDataExport } from '@/hooks' + +function ReportExporter() { + const { filteredData, addFilter } = useFilterableData(timesheets) + const { exportToCSV } = useDataExport() + + const exportFiltered = () => { + addFilter({ field: 'status', operator: 'equals', value: 'approved' }) + + exportToCSV(filteredData, { + filename: 'approved-timesheets', + columns: ['workerName', 'hours', 'amount', 'weekEnding'] + }) + } + + return +} +``` diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2c69742..fd10a43 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -58,6 +58,17 @@ export { useDragAndDrop } from './use-drag-and-drop' export { useCache } from './use-cache' export { useWebSocket } from './use-websocket' export { useEventBus } from './use-event-bus' +export { useRateCalculator } from './use-rate-calculator' +export { useAuditLog } from './use-audit-log' +export { useRecurringSchedule } from './use-recurring-schedule' +export { useComplianceCheck } from './use-compliance-check' +export { useApprovalWorkflow } from './use-approval-workflow' +export { useDataExport } from './use-data-export' +export { useHistory } from './use-history' +export { useSortableData } from './use-sortable-data' +export { useFilterableData } from './use-filterable-data' +export { useFormatter } from './use-formatter' +export { useTemplateManager } from './use-template-manager' export type { AsyncState } from './use-async' export type { FormErrors } from './use-form-validation' @@ -90,4 +101,15 @@ export type { DragItem, DropZone, DragState } from './use-drag-and-drop' export type { CacheOptions } from './use-cache' export type { WebSocketOptions } from './use-websocket' export type { EventBusEvent, EventHandler } from './use-event-bus' +export type { RateBreakdown, RateCalculationOptions } from './use-rate-calculator' +export type { AuditEntry } from './use-audit-log' +export type { RecurringSchedule, ScheduleInstance } from './use-recurring-schedule' +export type { ComplianceRule, ComplianceResult, ComplianceCheck } from './use-compliance-check' +export type { ApprovalStep, ApprovalWorkflow } from './use-approval-workflow' +export type { ExportOptions } from './use-data-export' +export type { HistoryState, UseHistoryReturn } from './use-history' +export type { SortConfig, UseSortableDataReturn } from './use-sortable-data' +export type { FilterRule, FilterOperator, UseFilterableDataReturn } from './use-filterable-data' +export type { FormatType, FormatOptions } from './use-formatter' +export type { Template } from './use-template-manager' diff --git a/src/hooks/use-approval-workflow.ts b/src/hooks/use-approval-workflow.ts new file mode 100644 index 0000000..bdaff12 --- /dev/null +++ b/src/hooks/use-approval-workflow.ts @@ -0,0 +1,162 @@ +import { useState, useCallback } from 'react' +import { useKV } from '@github/spark/hooks' + +export interface ApprovalStep { + id: string + order: number + approverRole: string + approverName?: string + status: 'pending' | 'approved' | 'rejected' | 'skipped' + approvedDate?: string + rejectedDate?: string + comments?: string +} + +export interface ApprovalWorkflow { + id: string + entityType: string + entityId: string + status: 'pending' | 'in-progress' | 'approved' | 'rejected' + steps: ApprovalStep[] + currentStepIndex: number + createdDate: string + completedDate?: string +} + +export function useApprovalWorkflow() { + const [workflows = [], setWorkflows] = useKV('approval-workflows', []) + + const createWorkflow = useCallback( + ( + entityType: string, + entityId: string, + approverRoles: string[] + ): ApprovalWorkflow => { + const workflow: ApprovalWorkflow = { + id: `WF-${Date.now()}`, + entityType, + entityId, + status: 'pending', + currentStepIndex: 0, + createdDate: new Date().toISOString(), + steps: approverRoles.map((role, index) => ({ + id: `STEP-${Date.now()}-${index}`, + order: index, + approverRole: role, + status: 'pending', + })), + } + + setWorkflows((current) => [...(current || []), workflow]) + return workflow + }, + [setWorkflows] + ) + + const approveStep = useCallback( + async (workflowId: string, stepId: string, comments?: string) => { + const user = await window.spark.user() + if (!user) return + + setWorkflows((current) => { + if (!current) return [] + return current.map((wf) => { + if (wf.id !== workflowId) return wf + + const updatedSteps = wf.steps.map((step) => { + if (step.id === stepId) { + return { + ...step, + status: 'approved' as const, + approverName: user.login, + approvedDate: new Date().toISOString(), + comments, + } + } + return step + }) + + const currentStep = updatedSteps.find((s) => s.id === stepId) + const isLastStep = + currentStep && currentStep.order === updatedSteps.length - 1 + const allApproved = updatedSteps.every((s) => s.status === 'approved') + + return { + ...wf, + steps: updatedSteps, + currentStepIndex: isLastStep + ? wf.currentStepIndex + : wf.currentStepIndex + 1, + status: allApproved ? ('approved' as const) : ('in-progress' as const), + completedDate: allApproved ? new Date().toISOString() : undefined, + } + }) + }) + }, + [setWorkflows] + ) + + const rejectStep = useCallback( + async (workflowId: string, stepId: string, comments?: string) => { + const user = await window.spark.user() + if (!user) return + + setWorkflows((current) => { + if (!current) return [] + return current.map((wf) => { + if (wf.id !== workflowId) return wf + + const updatedSteps = wf.steps.map((step) => { + if (step.id === stepId) { + return { + ...step, + status: 'rejected' as const, + approverName: user.login, + rejectedDate: new Date().toISOString(), + comments, + } + } + return step + }) + + return { + ...wf, + steps: updatedSteps, + status: 'rejected' as const, + completedDate: new Date().toISOString(), + } + }) + }) + }, + [setWorkflows] + ) + + const getWorkflowsByEntity = useCallback( + (entityType: string, entityId: string) => { + return workflows.filter( + (wf) => wf.entityType === entityType && wf.entityId === entityId + ) + }, + [workflows] + ) + + const getPendingWorkflows = useCallback(() => { + return workflows.filter( + (wf) => wf.status === 'pending' || wf.status === 'in-progress' + ) + }, [workflows]) + + const getCurrentStep = useCallback((workflow: ApprovalWorkflow) => { + return workflow.steps[workflow.currentStepIndex] + }, []) + + return { + workflows, + createWorkflow, + approveStep, + rejectStep, + getWorkflowsByEntity, + getPendingWorkflows, + getCurrentStep, + } +} diff --git a/src/hooks/use-audit-log.ts b/src/hooks/use-audit-log.ts new file mode 100644 index 0000000..0d85382 --- /dev/null +++ b/src/hooks/use-audit-log.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react' +import { useKV } from '@github/spark/hooks' + +export interface AuditEntry { + id: string + timestamp: string + userId: string + userName: string + action: string + entityType: string + entityId: string + changes?: Record + metadata?: Record +} + +export function useAuditLog() { + const [auditLog = [], setAuditLog] = useKV('audit-log', []) + + const logAction = useCallback( + async ( + action: string, + entityType: string, + entityId: string, + changes?: Record, + metadata?: Record + ) => { + const user = await window.spark.user() + if (!user) return + + const entry: AuditEntry = { + id: `AUDIT-${Date.now()}`, + timestamp: new Date().toISOString(), + userId: String(user.id), + userName: user.login, + action, + entityType, + entityId, + changes, + metadata, + } + + setAuditLog((current) => [entry, ...(current || [])]) + }, + [setAuditLog] + ) + + const getLogsByEntity = useCallback( + (entityType: string, entityId: string) => { + return auditLog.filter( + (entry) => entry.entityType === entityType && entry.entityId === entityId + ) + }, + [auditLog] + ) + + const getLogsByUser = useCallback( + (userId: string) => { + return auditLog.filter((entry) => entry.userId === userId) + }, + [auditLog] + ) + + const getLogsByDateRange = useCallback( + (startDate: Date, endDate: Date) => { + return auditLog.filter((entry) => { + const entryDate = new Date(entry.timestamp) + return entryDate >= startDate && entryDate <= endDate + }) + }, + [auditLog] + ) + + const clearLog = useCallback(() => { + setAuditLog([]) + }, [setAuditLog]) + + return { + auditLog, + logAction, + getLogsByEntity, + getLogsByUser, + getLogsByDateRange, + clearLog, + } +} diff --git a/src/hooks/use-compliance-check.ts b/src/hooks/use-compliance-check.ts new file mode 100644 index 0000000..7551df1 --- /dev/null +++ b/src/hooks/use-compliance-check.ts @@ -0,0 +1,174 @@ +import { useMemo, useCallback } from 'react' +import { differenceInDays, parseISO } from 'date-fns' + +export interface ComplianceRule { + id: string + name: string + description: string + checkFunction: (data: any) => ComplianceResult + severity: 'info' | 'warning' | 'error' | 'critical' + autoResolve?: boolean +} + +export interface ComplianceResult { + passed: boolean + message: string + severity: 'info' | 'warning' | 'error' | 'critical' + details?: Record +} + +export interface ComplianceCheck { + ruleId: string + ruleName: string + result: ComplianceResult + timestamp: string +} + +export function useComplianceCheck() { + const defaultRules: ComplianceRule[] = useMemo( + () => [ + { + id: 'doc-expiry', + name: 'Document Expiry Check', + description: 'Checks if documents are expired or expiring soon', + severity: 'error', + checkFunction: (doc: { expiryDate: string }) => { + const daysUntilExpiry = differenceInDays( + parseISO(doc.expiryDate), + new Date() + ) + + if (daysUntilExpiry < 0) { + return { + passed: false, + message: 'Document has expired', + severity: 'critical', + details: { daysOverdue: Math.abs(daysUntilExpiry) }, + } + } else if (daysUntilExpiry < 30) { + return { + passed: false, + message: `Document expires in ${daysUntilExpiry} days`, + severity: 'warning', + details: { daysUntilExpiry }, + } + } + + return { + passed: true, + message: 'Document is valid', + severity: 'info', + } + }, + }, + { + id: 'rate-validation', + name: 'Rate Validation', + description: 'Validates that rates are within acceptable ranges', + severity: 'warning', + checkFunction: (data: { rate: number; minRate?: number; maxRate?: number }) => { + const { rate, minRate = 0, maxRate = 10000 } = data + + if (rate < minRate) { + return { + passed: false, + message: `Rate £${rate} is below minimum £${minRate}`, + severity: 'error', + } + } else if (rate > maxRate) { + return { + passed: false, + message: `Rate £${rate} exceeds maximum £${maxRate}`, + severity: 'warning', + } + } + + return { + passed: true, + message: 'Rate is within acceptable range', + severity: 'info', + } + }, + }, + { + id: 'hours-validation', + name: 'Working Hours Validation', + description: 'Validates weekly working hours limits', + severity: 'warning', + checkFunction: (data: { weeklyHours: number; maxHours?: number }) => { + const { weeklyHours, maxHours = 48 } = data + + if (weeklyHours > maxHours) { + return { + passed: false, + message: `Weekly hours ${weeklyHours} exceed limit of ${maxHours}`, + severity: 'error', + details: { excess: weeklyHours - maxHours }, + } + } else if (weeklyHours > maxHours * 0.9) { + return { + passed: false, + message: `Weekly hours ${weeklyHours} approaching limit`, + severity: 'warning', + } + } + + return { + passed: true, + message: 'Working hours within limits', + severity: 'info', + } + }, + }, + ], + [] + ) + + const runCheck = useCallback( + (rule: ComplianceRule, data: any): ComplianceCheck => { + const result = rule.checkFunction(data) + return { + ruleId: rule.id, + ruleName: rule.name, + result, + timestamp: new Date().toISOString(), + } + }, + [] + ) + + const runAllChecks = useCallback( + (rules: ComplianceRule[], data: any): ComplianceCheck[] => { + return rules.map((rule) => runCheck(rule, data)) + }, + [runCheck] + ) + + const getFailedChecks = useCallback((checks: ComplianceCheck[]) => { + return checks.filter((check) => !check.result.passed) + }, []) + + const getCriticalChecks = useCallback((checks: ComplianceCheck[]) => { + return checks.filter((check) => check.result.severity === 'critical') + }, []) + + const hasFailures = useCallback((checks: ComplianceCheck[]) => { + return checks.some((check) => !check.result.passed) + }, []) + + const hasCriticalFailures = useCallback((checks: ComplianceCheck[]) => { + return checks.some( + (check) => !check.result.passed && check.result.severity === 'critical' + ) + }, []) + + return { + defaultRules, + runCheck, + runAllChecks, + getFailedChecks, + getCriticalChecks, + hasFailures, + hasCriticalFailures, + } +} diff --git a/src/hooks/use-data-export.ts b/src/hooks/use-data-export.ts new file mode 100644 index 0000000..2bebb03 --- /dev/null +++ b/src/hooks/use-data-export.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react' + +export type ExportFormat = 'csv' | 'json' | 'xlsx' + +export interface ExportOptions { + filename?: string + format?: ExportFormat + columns?: string[] + includeHeaders?: boolean +} + +export function useDataExport() { + const exportToCSV = useCallback( + (data: any[], options: ExportOptions = {}) => { + const { + filename = 'export', + columns, + includeHeaders = true, + } = options + + if (data.length === 0) { + throw new Error('No data to export') + } + + const keys = columns || Object.keys(data[0]) + let csv = '' + + if (includeHeaders) { + csv += keys.join(',') + '\n' + } + + data.forEach((row) => { + const values = keys.map((key) => { + const value = row[key] + if (value === null || value === undefined) return '' + const stringValue = String(value) + if (stringValue.includes(',') || stringValue.includes('"')) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue + }) + csv += values.join(',') + '\n' + }) + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = `${filename}.csv` + link.click() + URL.revokeObjectURL(link.href) + }, + [] + ) + + const exportToJSON = useCallback( + (data: any[], options: ExportOptions = {}) => { + const { filename = 'export' } = options + + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = `${filename}.json` + link.click() + URL.revokeObjectURL(link.href) + }, + [] + ) + + const exportData = useCallback( + (data: any[], options: ExportOptions = {}) => { + const { format = 'csv' } = options + + switch (format) { + case 'csv': + exportToCSV(data, options) + break + case 'json': + exportToJSON(data, options) + break + default: + throw new Error(`Unsupported export format: ${format}`) + } + }, + [exportToCSV, exportToJSON] + ) + + return { + exportToCSV, + exportToJSON, + exportData, + } +} diff --git a/src/hooks/use-filterable-data.ts b/src/hooks/use-filterable-data.ts new file mode 100644 index 0000000..1a40df3 --- /dev/null +++ b/src/hooks/use-filterable-data.ts @@ -0,0 +1,114 @@ +import { useState, useCallback, useMemo } from 'react' + +export type FilterOperator = + | 'equals' + | 'notEquals' + | 'contains' + | 'notContains' + | 'startsWith' + | 'endsWith' + | 'greaterThan' + | 'lessThan' + | 'greaterThanOrEqual' + | 'lessThanOrEqual' + | 'in' + | 'notIn' + +export interface FilterRule { + field: keyof T + operator: FilterOperator + value: any +} + +export interface UseFilterableDataReturn { + filteredData: T[] + filters: FilterRule[] + addFilter: (rule: FilterRule) => void + removeFilter: (index: number) => void + clearFilters: () => void + updateFilter: (index: number, rule: FilterRule) => void +} + +export function useFilterableData(data: T[]): UseFilterableDataReturn { + const [filters, setFilters] = useState[]>([]) + + const applyFilter = useCallback((item: T, rule: FilterRule): boolean => { + const value = item[rule.field] + + switch (rule.operator) { + case 'equals': + return value === rule.value + + case 'notEquals': + return value !== rule.value + + case 'contains': + return String(value).toLowerCase().includes(String(rule.value).toLowerCase()) + + case 'notContains': + return !String(value).toLowerCase().includes(String(rule.value).toLowerCase()) + + case 'startsWith': + return String(value).toLowerCase().startsWith(String(rule.value).toLowerCase()) + + case 'endsWith': + return String(value).toLowerCase().endsWith(String(rule.value).toLowerCase()) + + case 'greaterThan': + return Number(value) > Number(rule.value) + + case 'lessThan': + return Number(value) < Number(rule.value) + + case 'greaterThanOrEqual': + return Number(value) >= Number(rule.value) + + case 'lessThanOrEqual': + return Number(value) <= Number(rule.value) + + case 'in': + return Array.isArray(rule.value) && rule.value.includes(value) + + case 'notIn': + return Array.isArray(rule.value) && !rule.value.includes(value) + + default: + return true + } + }, []) + + const filteredData = useMemo(() => { + if (filters.length === 0) return data + + return data.filter((item) => { + return filters.every((rule) => applyFilter(item, rule)) + }) + }, [data, filters, applyFilter]) + + const addFilter = useCallback((rule: FilterRule) => { + setFilters((current) => [...current, rule]) + }, []) + + const removeFilter = useCallback((index: number) => { + setFilters((current) => current.filter((_, i) => i !== index)) + }, []) + + const clearFilters = useCallback(() => { + setFilters([]) + }, []) + + const updateFilter = useCallback((index: number, rule: FilterRule) => { + setFilters((current) => + current.map((filter, i) => (i === index ? rule : filter)) + ) + }, []) + + return { + filteredData, + filters, + addFilter, + removeFilter, + clearFilters, + updateFilter, + } +} diff --git a/src/hooks/use-formatter.ts b/src/hooks/use-formatter.ts new file mode 100644 index 0000000..f79c544 --- /dev/null +++ b/src/hooks/use-formatter.ts @@ -0,0 +1,124 @@ +import { useCallback } from 'react' +import { format, parseISO } from 'date-fns' + +export type FormatType = 'currency' | 'number' | 'percent' | 'date' | 'time' | 'datetime' + +export interface FormatOptions { + locale?: string + currency?: string + decimals?: number + dateFormat?: string + timeFormat?: string +} + +export function useFormatter(defaultOptions: FormatOptions = {}) { + const formatCurrency = useCallback( + (value: number, options: FormatOptions = {}) => { + const { locale = 'en-GB', currency = 'GBP', decimals = 2 } = { + ...defaultOptions, + ...options, + } + + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value) + }, + [defaultOptions] + ) + + const formatNumber = useCallback( + (value: number, options: FormatOptions = {}) => { + const { locale = 'en-GB', decimals = 2 } = { + ...defaultOptions, + ...options, + } + + return new Intl.NumberFormat(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value) + }, + [defaultOptions] + ) + + const formatPercent = useCallback( + (value: number, options: FormatOptions = {}) => { + const { locale = 'en-GB', decimals = 1 } = { + ...defaultOptions, + ...options, + } + + return new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value / 100) + }, + [defaultOptions] + ) + + const formatDate = useCallback( + (value: string | Date, options: FormatOptions = {}) => { + const { dateFormat = 'dd MMM yyyy' } = { ...defaultOptions, ...options } + const date = typeof value === 'string' ? parseISO(value) : value + return format(date, dateFormat) + }, + [defaultOptions] + ) + + const formatTime = useCallback( + (value: string | Date, options: FormatOptions = {}) => { + const { timeFormat = 'HH:mm' } = { ...defaultOptions, ...options } + const date = typeof value === 'string' ? parseISO(value) : value + return format(date, timeFormat) + }, + [defaultOptions] + ) + + const formatDateTime = useCallback( + (value: string | Date, options: FormatOptions = {}) => { + const { dateFormat = 'dd MMM yyyy', timeFormat = 'HH:mm' } = { + ...defaultOptions, + ...options, + } + const date = typeof value === 'string' ? parseISO(value) : value + return format(date, `${dateFormat} ${timeFormat}`) + }, + [defaultOptions] + ) + + const formatValue = useCallback( + (value: any, type: FormatType, options: FormatOptions = {}) => { + switch (type) { + case 'currency': + return formatCurrency(value, options) + case 'number': + return formatNumber(value, options) + case 'percent': + return formatPercent(value, options) + case 'date': + return formatDate(value, options) + case 'time': + return formatTime(value, options) + case 'datetime': + return formatDateTime(value, options) + default: + return String(value) + } + }, + [formatCurrency, formatNumber, formatPercent, formatDate, formatTime, formatDateTime] + ) + + return { + formatCurrency, + formatNumber, + formatPercent, + formatDate, + formatTime, + formatDateTime, + formatValue, + } +} diff --git a/src/hooks/use-history.ts b/src/hooks/use-history.ts new file mode 100644 index 0000000..fa25040 --- /dev/null +++ b/src/hooks/use-history.ts @@ -0,0 +1,97 @@ +import { useState, useCallback, useEffect } from 'react' + +export interface HistoryState { + past: T[] + present: T + future: T[] +} + +export interface UseHistoryReturn { + state: T + setState: (newState: T | ((prev: T) => T)) => void + undo: () => void + redo: () => void + clear: () => void + canUndo: boolean + canRedo: boolean + history: HistoryState +} + +export function useHistory(initialState: T, maxHistory = 50): UseHistoryReturn { + const [history, setHistory] = useState>({ + past: [], + present: initialState, + future: [], + }) + + const setState = useCallback( + (newState: T | ((prev: T) => T)) => { + setHistory((current) => { + const next = typeof newState === 'function' + ? (newState as (prev: T) => T)(current.present) + : newState + + if (next === current.present) return current + + return { + past: [...current.past, current.present].slice(-maxHistory), + present: next, + future: [], + } + }) + }, + [maxHistory] + ) + + const undo = useCallback(() => { + setHistory((current) => { + if (current.past.length === 0) return current + + const previous = current.past[current.past.length - 1] + const newPast = current.past.slice(0, -1) + + return { + past: newPast, + present: previous, + future: [current.present, ...current.future], + } + }) + }, []) + + const redo = useCallback(() => { + setHistory((current) => { + if (current.future.length === 0) return current + + const next = current.future[0] + const newFuture = current.future.slice(1) + + return { + past: [...current.past, current.present], + present: next, + future: newFuture, + } + }) + }, []) + + const clear = useCallback(() => { + setHistory((current) => ({ + past: [], + present: current.present, + future: [], + })) + }, []) + + const canUndo = history.past.length > 0 + const canRedo = history.future.length > 0 + + return { + state: history.present, + setState, + undo, + redo, + clear, + canUndo, + canRedo, + history, + } +} diff --git a/src/hooks/use-rate-calculator.ts b/src/hooks/use-rate-calculator.ts new file mode 100644 index 0000000..c50d4fe --- /dev/null +++ b/src/hooks/use-rate-calculator.ts @@ -0,0 +1,96 @@ +import { useMemo } from 'react' + +export interface RateBreakdown { + baseRate: number + overtimeRate: number + nightShiftPremium: number + weekendPremium: number + holidayPremium: number + totalRate: number +} + +export interface RateCalculationOptions { + baseRate: number + hours: number + isOvertime?: boolean + isNightShift?: boolean + isWeekend?: boolean + isHoliday?: boolean + overtimeMultiplier?: number + nightShiftMultiplier?: number + weekendMultiplier?: number + holidayMultiplier?: number +} + +export function useRateCalculator() { + const calculateRate = useMemo(() => { + return (options: RateCalculationOptions): RateBreakdown => { + const { + baseRate, + hours, + isOvertime = false, + isNightShift = false, + isWeekend = false, + isHoliday = false, + overtimeMultiplier = 1.5, + nightShiftMultiplier = 1.25, + weekendMultiplier = 1.5, + holidayMultiplier = 2.0, + } = options + + let effectiveRate = baseRate + const breakdown: RateBreakdown = { + baseRate, + overtimeRate: 0, + nightShiftPremium: 0, + weekendPremium: 0, + holidayPremium: 0, + totalRate: baseRate, + } + + if (isOvertime) { + breakdown.overtimeRate = baseRate * (overtimeMultiplier - 1) + effectiveRate *= overtimeMultiplier + } + + if (isNightShift) { + breakdown.nightShiftPremium = baseRate * (nightShiftMultiplier - 1) + effectiveRate *= nightShiftMultiplier + } + + if (isWeekend) { + breakdown.weekendPremium = baseRate * (weekendMultiplier - 1) + effectiveRate *= weekendMultiplier + } + + if (isHoliday) { + breakdown.holidayPremium = baseRate * (holidayMultiplier - 1) + effectiveRate *= holidayMultiplier + } + + breakdown.totalRate = effectiveRate + + return breakdown + } + }, []) + + const calculateTotalAmount = useMemo(() => { + return (options: RateCalculationOptions): number => { + const breakdown = calculateRate(options) + return breakdown.totalRate * options.hours + } + }, [calculateRate]) + + const calculateMargin = useMemo(() => { + return (chargeRate: number, payRate: number): number => { + if (chargeRate === 0) return 0 + return ((chargeRate - payRate) / chargeRate) * 100 + } + }, []) + + return { + calculateRate, + calculateTotalAmount, + calculateMargin, + } +} diff --git a/src/hooks/use-recurring-schedule.ts b/src/hooks/use-recurring-schedule.ts new file mode 100644 index 0000000..7318e5f --- /dev/null +++ b/src/hooks/use-recurring-schedule.ts @@ -0,0 +1,128 @@ +import { useState, useCallback, useMemo } from 'react' +import { addDays, startOfWeek, format, parseISO } from 'date-fns' + +export interface RecurringSchedule { + id: string + name: string + pattern: 'daily' | 'weekly' | 'biweekly' | 'monthly' + startDate: string + endDate?: string + daysOfWeek?: number[] + timeSlots: Array<{ + startTime: string + endTime: string + description?: string + }> +} + +export interface ScheduleInstance { + date: string + dayOfWeek: number + timeSlots: Array<{ + startTime: string + endTime: string + description?: string + }> +} + +export function useRecurringSchedule() { + const [schedules, setSchedules] = useState([]) + + const addSchedule = useCallback((schedule: Omit) => { + const newSchedule: RecurringSchedule = { + ...schedule, + id: `SCHED-${Date.now()}`, + } + setSchedules((prev) => [...prev, newSchedule]) + return newSchedule + }, []) + + const removeSchedule = useCallback((id: string) => { + setSchedules((prev) => prev.filter((s) => s.id !== id)) + }, []) + + const updateSchedule = useCallback( + (id: string, updates: Partial) => { + setSchedules((prev) => + prev.map((s) => (s.id === id ? { ...s, ...updates } : s)) + ) + }, + [] + ) + + const generateInstances = useCallback( + (schedule: RecurringSchedule, startDate: Date, endDate: Date): ScheduleInstance[] => { + const instances: ScheduleInstance[] = [] + const scheduleStart = parseISO(schedule.startDate) + const scheduleEnd = schedule.endDate ? parseISO(schedule.endDate) : endDate + + let currentDate = scheduleStart > startDate ? scheduleStart : startDate + + while (currentDate <= endDate && currentDate <= scheduleEnd) { + const dayOfWeek = currentDate.getDay() + + if (!schedule.daysOfWeek || schedule.daysOfWeek.includes(dayOfWeek)) { + instances.push({ + date: format(currentDate, 'yyyy-MM-dd'), + dayOfWeek, + timeSlots: schedule.timeSlots, + }) + } + + switch (schedule.pattern) { + case 'daily': + currentDate = addDays(currentDate, 1) + break + case 'weekly': + currentDate = addDays(currentDate, 7) + break + case 'biweekly': + currentDate = addDays(currentDate, 14) + break + case 'monthly': + currentDate = addDays(currentDate, 30) + break + } + } + + return instances + }, + [] + ) + + const getScheduleForDate = useCallback( + (date: Date): ScheduleInstance[] => { + const dateStr = format(date, 'yyyy-MM-dd') + const dayOfWeek = date.getDay() + + return schedules + .filter((schedule) => { + const scheduleStart = parseISO(schedule.startDate) + const scheduleEnd = schedule.endDate + ? parseISO(schedule.endDate) + : new Date('2099-12-31') + + return ( + date >= scheduleStart && + date <= scheduleEnd && + (!schedule.daysOfWeek || schedule.daysOfWeek.includes(dayOfWeek)) + ) + }) + .map((schedule) => ({ + date: dateStr, + dayOfWeek, + timeSlots: schedule.timeSlots, + })) + }, + [schedules] + ) + + return { + schedules, + addSchedule, + removeSchedule, + updateSchedule, + generateInstances, + getScheduleForDate, + } +} diff --git a/src/hooks/use-sortable-data.ts b/src/hooks/use-sortable-data.ts new file mode 100644 index 0000000..131b3cf --- /dev/null +++ b/src/hooks/use-sortable-data.ts @@ -0,0 +1,87 @@ +import { useState, useCallback, useMemo } from 'react' + +export type SortDirection = 'asc' | 'desc' + +export interface SortConfig { + key: keyof T + direction: SortDirection +} + +export interface UseSortableDataReturn { + sortedData: T[] + sortConfig: SortConfig | null + requestSort: (key: keyof T) => void + clearSort: () => void +} + +export function useSortableData( + data: T[], + defaultConfig?: SortConfig +): UseSortableDataReturn { + const [sortConfig, setSortConfig] = useState | null>( + defaultConfig || null + ) + + const sortedData = useMemo(() => { + if (!sortConfig) return data + + const sorted = [...data].sort((a, b) => { + const aValue = a[sortConfig.key] + const bValue = b[sortConfig.key] + + if (aValue === bValue) return 0 + + if (aValue === null || aValue === undefined) return 1 + if (bValue === null || bValue === undefined) return -1 + + if (typeof aValue === 'string' && typeof bValue === 'string') { + const comparison = aValue.localeCompare(bValue) + return sortConfig.direction === 'asc' ? comparison : -comparison + } + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue + } + + if (aValue instanceof Date && bValue instanceof Date) { + return sortConfig.direction === 'asc' + ? aValue.getTime() - bValue.getTime() + : bValue.getTime() - aValue.getTime() + } + + return sortConfig.direction === 'asc' + ? String(aValue).localeCompare(String(bValue)) + : String(bValue).localeCompare(String(aValue)) + }) + + return sorted + }, [data, sortConfig]) + + const requestSort = useCallback( + (key: keyof T) => { + setSortConfig((current) => { + if (!current || current.key !== key) { + return { key, direction: 'asc' } + } + + if (current.direction === 'asc') { + return { key, direction: 'desc' } + } + + return null + }) + }, + [] + ) + + const clearSort = useCallback(() => { + setSortConfig(null) + }, []) + + return { + sortedData, + sortConfig, + requestSort, + clearSort, + } +} diff --git a/src/hooks/use-template-manager.ts b/src/hooks/use-template-manager.ts new file mode 100644 index 0000000..b98e209 --- /dev/null +++ b/src/hooks/use-template-manager.ts @@ -0,0 +1,114 @@ +import { useCallback } from 'react' +import { useKV } from '@github/spark/hooks' + +export interface Template { + id: string + name: string + description?: string + category?: string + content: any + createdDate: string + modifiedDate: string +} + +export function useTemplateManager(storageKey: string) { + const [templates = [], setTemplates] = useKV(storageKey, []) + + const createTemplate = useCallback( + (name: string, content: T, description?: string, category?: string): Template => { + const template: Template = { + id: `TPL-${Date.now()}`, + name, + description, + category, + content, + createdDate: new Date().toISOString(), + modifiedDate: new Date().toISOString(), + } + + setTemplates((current) => [...(current || []), template]) + return template + }, + [setTemplates] + ) + + const updateTemplate = useCallback( + (id: string, updates: Partial>) => { + setTemplates((current) => { + if (!current) return [] + return current.map((template) => + template.id === id + ? { + ...template, + ...updates, + modifiedDate: new Date().toISOString(), + } + : template + ) + }) + }, + [setTemplates] + ) + + const deleteTemplate = useCallback( + (id: string) => { + setTemplates((current) => { + if (!current) return [] + return current.filter((template) => template.id !== id) + }) + }, + [setTemplates] + ) + + const getTemplate = useCallback( + (id: string): Template | undefined => { + return templates.find((template) => template.id === id) + }, + [templates] + ) + + const getTemplatesByCategory = useCallback( + (category: string): Template[] => { + return templates.filter((template) => template.category === category) + }, + [templates] + ) + + const duplicateTemplate = useCallback( + (id: string): Template | undefined => { + const original = templates.find((t) => t.id === id) + if (!original) return undefined + + const duplicate: Template = { + ...original, + id: `TPL-${Date.now()}`, + name: `${original.name} (Copy)`, + createdDate: new Date().toISOString(), + modifiedDate: new Date().toISOString(), + } + + setTemplates((current) => [...(current || []), duplicate]) + return duplicate + }, + [templates, setTemplates] + ) + + const applyTemplate = useCallback( + (id: string): T | undefined => { + const template = templates.find((t) => t.id === id) + return template?.content as T | undefined + }, + [templates] + ) + + return { + templates, + createTemplate, + updateTemplate, + deleteTemplate, + getTemplate, + getTemplatesByCategory, + duplicateTemplate, + applyTemplate, + } +}