Generated by Spark: Expand custom hook library, expand ui component library

This commit is contained in:
2026-01-23 06:39:49 +00:00
committed by GitHub
parent e222d7679f
commit a0a0c60dc0
26 changed files with 3167 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
# New UI Components
This document describes the newly added UI components for WorkForce Pro platform.
## Display Components
### `<Timeline />`
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: <CheckCircle />
},
{
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'
}
]
<Timeline items={items} />
```
### `<DescriptionList />`
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: <Badge>Full Time</Badge> },
{ label: 'Rate', value: '£25.00/hr' },
{ label: 'Total Amount', value: '£1,000.00' }
]
<DescriptionList items={items} layout="horizontal" />
<DescriptionList items={items} layout="grid" columns={2} />
```
### `<StatusIndicator />`
Visual status indicators with optional pulse animation.
```tsx
import { StatusIndicator } from '@/components/ui/status-indicator'
<StatusIndicator status="success" label="Active" />
<StatusIndicator status="pending" label="Awaiting Approval" pulse />
<StatusIndicator status="error" label="Expired" size="lg" />
```
**Status types:** `success`, `warning`, `error`, `info`, `default`, `pending`, `approved`, `rejected`
### `<ActivityLog />`
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: <Pencil />,
metadata: {
'Previous Hours': '40',
'New Hours': '45',
'Reason': 'Client requested adjustment'
}
}
]
<ActivityLog entries={entries} />
```
### `<NotificationList />`
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')
}
}
]
<NotificationList
notifications={notifications}
onDismiss={(id) => removeNotification(id)}
onNotificationClick={(notification) => markAsRead(notification.id)}
/>
```
## Navigation & Flow Components
### `<Wizard />`
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: <WorkerDetailsForm />,
validate: async () => {
// Return true if valid, false otherwise
return form.workerName && form.clientName
}
},
{
id: 'hours',
title: 'Hours',
description: 'Enter working hours',
content: <HoursForm />
},
{
id: 'review',
title: 'Review',
description: 'Review and submit',
content: <ReviewStep />
}
]
<Wizard
steps={steps}
onComplete={(data) => submitTimesheet(data)}
onCancel={() => navigate('/timesheets')}
showStepIndicator
/>
```
### `<Stepper />`
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' }
]
<Stepper steps={steps} orientation="horizontal" currentStep={1} />
<Stepper steps={steps} orientation="vertical" />
```
## Data Display Components
### `<SimpleTable />`
Lightweight table with custom rendering.
```tsx
import { SimpleTable, DataTableColumn } from '@/components/ui/simple-table'
const columns: DataTableColumn<Timesheet>[] = [
{ 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) => <Badge>{value}</Badge>
}
]
<SimpleTable
data={timesheets}
columns={columns}
onRowClick={(row) => navigate(`/timesheets/${row.id}`)}
striped
hoverable
/>
```
## Input & Action Components
### `<PaginationButtons />`
Full-featured pagination with page numbers.
```tsx
import { PaginationButtons } from '@/components/ui/pagination-buttons'
<PaginationButtons
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(page) => setCurrentPage(page)}
showFirstLast
maxButtons={7}
/>
```
### `<ExportButton />`
Multi-format export button with dropdown.
```tsx
import { ExportButton } from '@/components/ui/export-button'
<ExportButton
onExport={(format) => {
if (format === 'csv') exportToCSV(data)
if (format === 'json') exportToJSON(data)
}}
formats={['csv', 'json', 'xlsx']}
variant="outline"
/>
// Single format (no dropdown)
<ExportButton
onExport={(format) => exportToCSV(data)}
formats={['csv']}
/>
```
### `<FilterChipsBar />`
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' }
]
<FilterChipsBar
filters={filters}
onRemove={(id) => removeFilter(id)}
onClearAll={() => clearAllFilters()}
showClearAll
/>
```
### `<QuickSearch />`
Debounced search input with icon.
```tsx
import { QuickSearch } from '@/components/ui/quick-search'
<QuickSearch
onSearch={(value) => 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<QuickSearch onSearch={(q) => addFilter({
field: 'workerName',
operator: 'contains',
value: q
})} />
<ExportButton
onExport={(format) => exportToCSV(sortedData, { filename: 'timesheets' })}
formats={['csv', 'json']}
/>
</div>
<FilterChipsBar
filters={filters}
onRemove={removeFilter}
onClearAll={clearFilters}
/>
<SimpleTable
data={sortedData}
columns={columns}
onRowClick={(row) => openDetails(row)}
/>
</div>
)
}
```
### 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 (
<div className="space-y-6">
<Stepper
steps={workflow.steps.map(s => ({
label: s.approverRole,
status: s.status
}))}
currentStep={workflow.currentStepIndex}
/>
<ActivityLog entries={logs} />
<Button onClick={() => approveStep(workflow.id, currentStepId)}>
Approve
</Button>
</div>
)
}
```
### 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: <StatusIndicator status={timesheet.status} label={timesheet.status} /> },
{ label: 'Hours', value: timesheet.hours },
{ label: 'Amount', value: formatCurrency(timesheet.amount) }
]
return (
<div className="grid grid-cols-2 gap-6">
<DescriptionList items={details} layout="vertical" />
<Timeline items={timeline} />
</div>
)
}
```

View File

@@ -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<string, any>
}
export interface ActivityLogProps extends React.HTMLAttributes<HTMLDivElement> {
entries: ActivityLogEntry[]
emptyMessage?: string
}
const ActivityLog = React.forwardRef<HTMLDivElement, ActivityLogProps>(
({ entries, emptyMessage = 'No activity yet', className, ...props }, ref) => {
if (entries.length === 0) {
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center p-8 text-muted-foreground',
className
)}
{...props}
>
{emptyMessage}
</div>
)
}
return (
<div ref={ref} className={cn('space-y-4', className)} {...props}>
{entries.map((entry, index) => (
<div
key={entry.id}
className={cn('relative flex gap-4', index !== entries.length - 1 && 'pb-4')}
>
{index !== entries.length - 1 && (
<div className="absolute left-5 top-10 bottom-0 w-px bg-border" />
)}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted border-2 border-background">
{entry.icon || (
<div className="h-2 w-2 rounded-full bg-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
{entry.user && (
<span className="text-sm font-medium">{entry.user.name}</span>
)}
<span className="text-sm text-muted-foreground">{entry.action}</span>
<time className="text-xs text-muted-foreground ml-auto">
{entry.timestamp}
</time>
</div>
<p className="text-sm text-muted-foreground">{entry.description}</p>
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
<div className="mt-2 rounded-md bg-muted p-2 text-xs space-y-1">
{Object.entries(entry.metadata).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="font-medium text-muted-foreground">{key}:</span>
<span className="text-foreground">{String(value)}</span>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)
}
)
ActivityLog.displayName = 'ActivityLog'
export { ActivityLog }

View File

@@ -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<HTMLDListElement> {
items: DataValue[]
layout?: 'vertical' | 'horizontal' | 'grid'
columns?: 1 | 2 | 3 | 4
}
const DescriptionList = React.forwardRef<HTMLDListElement, DescriptionListProps>(
({ 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 (
<dl
ref={ref}
className={cn(layoutStyles[layout], className)}
{...props}
>
{items.map((item, index) => (
<div
key={index}
className={cn(
layout === 'horizontal' && 'grid grid-cols-3 gap-4',
layout === 'vertical' && 'flex flex-col gap-1',
layout === 'grid' && 'flex flex-col gap-1'
)}
>
<dt className="text-sm font-medium text-muted-foreground flex items-center gap-2">
{item.label}
{item.badge}
</dt>
<dd
className={cn(
'text-sm',
layout === 'horizontal' && 'col-span-2',
typeof item.value === 'string' || typeof item.value === 'number'
? 'text-foreground'
: ''
)}
>
{item.value}
</dd>
</div>
))}
</dl>
)
}
)
DescriptionList.displayName = 'DescriptionList'
export { DescriptionList }

View File

@@ -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<HTMLButtonElement> {
onExport: (format: 'csv' | 'json' | 'xlsx') => void
formats?: Array<'csv' | 'json' | 'xlsx'>
variant?: 'default' | 'outline' | 'ghost'
size?: 'default' | 'sm' | 'lg'
}
const ExportButton = React.forwardRef<HTMLButtonElement, ExportButtonProps>(
(
{
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 (
<Button
ref={ref}
variant={variant}
size={size}
onClick={() => handleExport(formats[0])}
className={className}
{...props}
>
<Download className="mr-2" />
Export {formats[0].toUpperCase()}
</Button>
)
}
return (
<div className="relative">
<Button
ref={ref}
variant={variant}
size={size}
onClick={() => setIsOpen(!isOpen)}
className={className}
{...props}
>
<Download className="mr-2" />
Export
</Button>
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-48 rounded-md border bg-popover shadow-lg z-50">
<div className="p-1">
{formats.includes('csv') && (
<button
onClick={() => handleExport('csv')}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm hover:bg-accent"
>
<Table />
Export as CSV
</button>
)}
{formats.includes('json') && (
<button
onClick={() => handleExport('json')}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm hover:bg-accent"
>
<FileText />
Export as JSON
</button>
)}
{formats.includes('xlsx') && (
<button
onClick={() => handleExport('xlsx')}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm hover:bg-accent"
>
<FileText />
Export as XLSX
</button>
)}
</div>
</div>
</>
)}
</div>
)
}
)
ExportButton.displayName = 'ExportButton'
export { ExportButton }

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Badge } from './badge'
import { X } from '@phosphor-icons/react'
export interface FilterChip {
id: string
label: string
value: string
field?: string
removable?: boolean
}
export interface FilterChipsBarProps extends React.HTMLAttributes<HTMLDivElement> {
filters: FilterChip[]
onRemove?: (id: string) => void
onClearAll?: () => void
showClearAll?: boolean
}
const FilterChipsBar = React.forwardRef<HTMLDivElement, FilterChipsBarProps>(
(
{
filters,
onRemove,
onClearAll,
showClearAll = true,
className,
...props
},
ref
) => {
if (filters.length === 0) return null
return (
<div
ref={ref}
className={cn('flex flex-wrap items-center gap-2', className)}
{...props}
>
<span className="text-sm text-muted-foreground">Filters:</span>
{filters.map((filter) => (
<Badge
key={filter.id}
variant="secondary"
className="flex items-center gap-1.5 pr-1"
>
<span className="text-xs">
{filter.field && (
<span className="font-semibold">{filter.field}: </span>
)}
{filter.label}
</span>
{filter.removable !== false && onRemove && (
<button
onClick={() => onRemove(filter.id)}
className="rounded-sm hover:bg-muted p-0.5"
>
<X size={12} />
</button>
)}
</Badge>
))}
{showClearAll && filters.length > 1 && onClearAll && (
<button
onClick={onClearAll}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Clear all
</button>
)}
</div>
)
}
)
FilterChipsBar.displayName = 'FilterChipsBar'
export { FilterChipsBar }

View File

@@ -0,0 +1,115 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { X } from '@phosphor-icons/react'
export interface NotificationItem {
id: string
title: string
message: string
type?: 'info' | 'success' | 'warning' | 'error'
timestamp?: string
read?: boolean
action?: {
label: string
onClick: () => void
}
}
export interface NotificationListProps extends React.HTMLAttributes<HTMLDivElement> {
notifications: NotificationItem[]
onDismiss?: (id: string) => void
onNotificationClick?: (notification: NotificationItem) => void
emptyMessage?: string
}
const notificationTypeStyles = {
info: 'border-l-info bg-info/5',
success: 'border-l-success bg-success/5',
warning: 'border-l-warning bg-warning/5',
error: 'border-l-destructive bg-destructive/5',
}
const NotificationList = React.forwardRef<HTMLDivElement, NotificationListProps>(
(
{
notifications,
onDismiss,
onNotificationClick,
emptyMessage = 'No notifications',
className,
...props
},
ref
) => {
if (notifications.length === 0) {
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center p-8 text-muted-foreground',
className
)}
{...props}
>
{emptyMessage}
</div>
)
}
return (
<div ref={ref} className={cn('space-y-2', className)} {...props}>
{notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'relative flex items-start gap-3 p-4 border-l-4 rounded-r-md cursor-pointer transition-colors hover:bg-muted/50',
notificationTypeStyles[notification.type || 'info'],
!notification.read && 'font-medium'
)}
onClick={() => onNotificationClick?.(notification)}
>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-semibold">{notification.title}</h4>
{notification.timestamp && (
<time className="text-xs text-muted-foreground whitespace-nowrap">
{notification.timestamp}
</time>
)}
</div>
<p className="text-sm text-muted-foreground">{notification.message}</p>
{notification.action && (
<button
onClick={(e) => {
e.stopPropagation()
notification.action?.onClick()
}}
className="text-xs font-medium text-accent hover:underline"
>
{notification.action.label}
</button>
)}
</div>
{onDismiss && (
<button
onClick={(e) => {
e.stopPropagation()
onDismiss(notification.id)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} />
</button>
)}
{!notification.read && (
<div className="absolute top-4 left-0 h-2 w-2 rounded-full bg-accent -translate-x-1/2" />
)}
</div>
))}
</div>
)
}
)
NotificationList.displayName = 'NotificationList'
export { NotificationList }

View File

@@ -0,0 +1,148 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { CaretLeft, CaretRight, DotsThree } from '@phosphor-icons/react'
export interface PaginationButtonsProps extends React.HTMLAttributes<HTMLDivElement> {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
showFirstLast?: boolean
maxButtons?: number
}
const PaginationButtons = React.forwardRef<HTMLDivElement, PaginationButtonsProps>(
(
{
currentPage,
totalPages,
onPageChange,
showFirstLast = true,
maxButtons = 7,
className,
...props
},
ref
) => {
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= maxButtons) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
const leftSiblingIndex = Math.max(currentPage - 1, 1)
const rightSiblingIndex = Math.min(currentPage + 1, totalPages)
const showLeftDots = leftSiblingIndex > 2
const showRightDots = rightSiblingIndex < totalPages - 1
if (!showLeftDots && showRightDots) {
const leftItemCount = 3
for (let i = 1; i <= leftItemCount; i++) {
pages.push(i)
}
pages.push('...')
pages.push(totalPages)
} else if (showLeftDots && !showRightDots) {
pages.push(1)
pages.push('...')
const rightItemCount = 3
for (let i = totalPages - rightItemCount + 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
pages.push(1)
pages.push('...')
for (let i = leftSiblingIndex; i <= rightSiblingIndex; i++) {
pages.push(i)
}
pages.push('...')
pages.push(totalPages)
}
}
return pages
}
const pages = getPageNumbers()
return (
<div
ref={ref}
className={cn('flex items-center gap-1', className)}
{...props}
>
{showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
First
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<CaretLeft />
</Button>
{pages.map((page, index) => {
if (page === '...') {
return (
<Button
key={`ellipsis-${index}`}
variant="ghost"
size="sm"
disabled
>
<DotsThree />
</Button>
)
}
return (
<Button
key={page}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(page as number)}
>
{page}
</Button>
)
})}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<CaretRight />
</Button>
{showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
Last
</Button>
)}
</div>
)
}
)
PaginationButtons.displayName = 'PaginationButtons'
export { PaginationButtons }

View File

@@ -0,0 +1,49 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Input } from './input'
import { MagnifyingGlass } from '@phosphor-icons/react'
export interface QuickSearchProps extends React.InputHTMLAttributes<HTMLInputElement> {
onSearch: (value: string) => void
debounceMs?: number
}
const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
({ onSearch, debounceMs = 300, className, ...props }, ref) => {
const [value, setValue] = React.useState<string>('')
const timeoutRef = React.useRef<number | undefined>(undefined)
React.useEffect(() => {
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onSearch(value)
}, debounceMs) as unknown as number
return () => {
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current)
}
}
}, [value, debounceMs, onSearch])
return (
<div className={cn('relative', className)}>
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
ref={ref}
value={value}
onChange={(e) => setValue(e.target.value)}
className="pl-10"
placeholder="Quick search..."
{...props}
/>
</div>
)
}
)
QuickSearch.displayName = 'QuickSearch'
export { QuickSearch }

View File

@@ -0,0 +1,123 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface DataTableColumn<T> {
key: keyof T | string
label: string
width?: string
align?: 'left' | 'center' | 'right'
render?: (value: any, row: T, index: number) => React.ReactNode
sortable?: boolean
}
export interface SimpleTableProps<T> extends React.HTMLAttributes<HTMLTableElement> {
data: T[]
columns: DataTableColumn<T>[]
onRowClick?: (row: T, index: number) => void
emptyMessage?: string
striped?: boolean
hoverable?: boolean
}
function SimpleTableInner<T>(
{
data,
columns,
onRowClick,
emptyMessage = 'No data available',
striped = true,
hoverable = true,
className,
...props
}: SimpleTableProps<T>,
ref: React.ForwardedRef<HTMLTableElement>
) {
const getCellValue = (row: T, column: DataTableColumn<T>) => {
if (typeof column.key === 'string' && column.key.includes('.')) {
const keys = column.key.split('.')
let value: any = row
for (const key of keys) {
value = value?.[key]
}
return value
}
return row[column.key as keyof T]
}
return (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
>
<thead className="border-b bg-muted/50">
<tr>
{columns.map((column, index) => (
<th
key={String(column.key) || index}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right'
)}
style={{ width: column.width }}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr
key={rowIndex}
onClick={() => onRowClick?.(row, rowIndex)}
className={cn(
'border-b transition-colors',
striped && rowIndex % 2 === 0 && 'bg-muted/20',
hoverable && onRowClick && 'cursor-pointer hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((column, colIndex) => {
const value = getCellValue(row, column)
const rendered = column.render
? column.render(value, row, rowIndex)
: value
return (
<td
key={String(column.key) || colIndex}
className={cn(
'p-4 align-middle',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right'
)}
>
{rendered}
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export const SimpleTable = React.forwardRef(SimpleTableInner) as <T>(
props: SimpleTableProps<T> & { ref?: React.ForwardedRef<HTMLTableElement> }
) => React.ReactElement

View File

@@ -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<HTMLDivElement> {
status: StatusType
label?: string
pulse?: boolean
size?: 'sm' | 'md' | 'lg'
}
const statusStyles: Record<StatusType, string> = {
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<HTMLDivElement, StatusIndicatorProps>(
({ status, label, pulse = false, size = 'md', className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('inline-flex items-center gap-2', className)}
{...props}
>
<div className="relative">
<div
className={cn(
'rounded-full',
statusStyles[status],
sizeStyles[size]
)}
/>
{pulse && (
<div
className={cn(
'absolute inset-0 rounded-full animate-ping opacity-75',
statusStyles[status],
sizeStyles[size]
)}
/>
)}
</div>
{label && <span className="text-sm">{label}</span>}
</div>
)
}
)
StatusIndicator.displayName = 'StatusIndicator'
export { StatusIndicator }

View File

@@ -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<HTMLDivElement> {
steps: Step[]
orientation?: 'horizontal' | 'vertical'
currentStep?: number
}
const Stepper = React.forwardRef<HTMLDivElement, StepperProps>(
(
{ steps, orientation = 'horizontal', currentStep = 0, className, ...props },
ref
) => {
return (
<div
ref={ref}
className={cn(
orientation === 'horizontal' && 'flex items-center',
orientation === 'vertical' && 'flex flex-col',
className
)}
{...props}
>
{steps.map((step, index) => {
const status =
step.status ||
(index < currentStep
? 'completed'
: index === currentStep
? 'current'
: 'upcoming')
const isLast = index === steps.length - 1
return (
<React.Fragment key={index}>
<div
className={cn(
'flex items-center gap-3',
orientation === 'vertical' && 'flex-col items-start'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
status === 'completed' &&
'border-success bg-success text-success-foreground',
status === 'current' &&
'border-accent bg-accent text-accent-foreground',
status === 'upcoming' &&
'border-border bg-muted text-muted-foreground',
status === 'error' &&
'border-destructive bg-destructive text-destructive-foreground'
)}
>
{step.icon || (status === 'completed' ? '✓' : index + 1)}
</div>
<div>
<div
className={cn(
'text-sm font-medium',
status === 'current' && 'text-foreground',
status !== 'current' && 'text-muted-foreground'
)}
>
{step.label}
</div>
{step.description && (
<div className="text-xs text-muted-foreground">
{step.description}
</div>
)}
</div>
</div>
</div>
{!isLast && (
<div
className={cn(
orientation === 'horizontal' && 'h-0.5 flex-1 mx-4',
orientation === 'vertical' && 'w-0.5 h-8 ml-5',
index < currentStep ? 'bg-success' : 'bg-border'
)}
/>
)}
</React.Fragment>
)
})}
</div>
)
}
)
Stepper.displayName = 'Stepper'
export { Stepper }

View File

@@ -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<HTMLDivElement, TimelineProps>(
({ items, className }, ref) => {
return (
<div ref={ref} className={cn('space-y-8', className)}>
{items.map((item, index) => (
<TimelineItem key={index} {...item} isLast={index === items.length - 1} />
))}
</div>
)
}
)
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 (
<div ref={ref} className="relative flex gap-4">
<div className="flex flex-col items-center">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2',
statusStyles[status]
)}
>
{icon || (
<div
className={cn(
'h-3 w-3 rounded-full',
status === 'completed' ? 'bg-success-foreground' : '',
status === 'current' ? 'bg-accent-foreground' : '',
status === 'upcoming' ? 'bg-muted-foreground/30' : ''
)}
/>
)}
</div>
{!isLast && (
<div
className={cn('mt-2 h-full w-0.5', lineStyles[status])}
style={{ minHeight: '3rem' }}
/>
)}
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2 mb-1">
<time className="text-sm text-muted-foreground font-mono">{date}</time>
</div>
<h4 className="font-semibold mb-1">{title}</h4>
{description && (
<p className="text-sm text-muted-foreground mb-2">{description}</p>
)}
{children && <div className="mt-3">{children}</div>}
</div>
</div>
)
})
TimelineItem.displayName = 'TimelineItem'
export { Timeline, TimelineItem }

View File

@@ -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<boolean>
}
export interface WizardProps {
steps: WizardStep[]
onComplete?: (data: any) => void
onCancel?: () => void
className?: string
showStepIndicator?: boolean
}
const Wizard = React.forwardRef<HTMLDivElement, WizardProps>(
(
{
steps,
onComplete,
onCancel,
className,
showStepIndicator = true,
},
ref
) => {
const [currentStepIndex, setCurrentStepIndex] = React.useState(0)
const [completedSteps, setCompletedSteps] = React.useState<Set<number>>(
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 (
<div ref={ref} className={cn('flex flex-col gap-6', className)}>
{showStepIndicator && (
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = completedSteps.has(index)
const isCurrent = index === currentStepIndex
const isAccessible = index <= currentStepIndex || isCompleted
return (
<React.Fragment key={step.id}>
<button
onClick={() => handleStepClick(index)}
disabled={!isAccessible}
className={cn(
'flex flex-col items-center gap-2 flex-1 group',
isAccessible && 'cursor-pointer',
!isAccessible && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2 transition-colors',
isCompleted && 'bg-success border-success text-success-foreground',
isCurrent && 'bg-accent border-accent text-accent-foreground',
!isCompleted && !isCurrent && 'bg-muted border-border'
)}
>
{isCompleted ? '✓' : index + 1}
</div>
<div className="text-center">
<div
className={cn(
'text-sm font-medium',
isCurrent && 'text-foreground',
!isCurrent && 'text-muted-foreground'
)}
>
{step.title}
</div>
{step.description && (
<div className="text-xs text-muted-foreground">
{step.description}
</div>
)}
</div>
</button>
{index < steps.length - 1 && (
<div
className={cn(
'h-0.5 flex-1 mt-5',
index < currentStepIndex ? 'bg-success' : 'bg-border'
)}
/>
)}
</React.Fragment>
)
})}
</div>
)}
<div className="flex-1">{currentStep.content}</div>
<div className="flex items-center justify-between pt-4 border-t">
<div>
{!isFirstStep && (
<Button variant="outline" onClick={handleBack}>
<CaretLeft className="mr-2" />
Back
</Button>
)}
{isFirstStep && onCancel && (
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
</div>
<div className="text-sm text-muted-foreground">
Step {currentStepIndex + 1} of {steps.length}
</div>
<Button onClick={handleNext}>
{isLastStep ? 'Complete' : 'Next'}
{!isLastStep && <CaretRight className="ml-2" />}
</Button>
</div>
</div>
)
}
)
Wizard.displayName = 'Wizard'
export { Wizard }

285
src/hooks/NEW_HOOKS.md Normal file
View File

@@ -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<T>(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<T>(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<T>(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<T>(storageKey)`
Create and manage reusable templates.
```tsx
const {
templates,
createTemplate,
updateTemplate,
deleteTemplate,
getTemplate,
duplicateTemplate,
applyTemplate
} = useTemplateManager<InvoiceTemplate>('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 <div>...</div>
}
```
### 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 <div>...</div>
}
```
### 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 <button onClick={exportFiltered}>Export Approved</button>
}
```

View File

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

View File

@@ -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<ApprovalWorkflow[]>('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,
}
}

View File

@@ -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<string, { old: any; new: any }>
metadata?: Record<string, any>
}
export function useAuditLog() {
const [auditLog = [], setAuditLog] = useKV<AuditEntry[]>('audit-log', [])
const logAction = useCallback(
async (
action: string,
entityType: string,
entityId: string,
changes?: Record<string, { old: any; new: any }>,
metadata?: Record<string, any>
) => {
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,
}
}

View File

@@ -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<string, any>
}
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,
}
}

View File

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

View File

@@ -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<T> {
field: keyof T
operator: FilterOperator
value: any
}
export interface UseFilterableDataReturn<T> {
filteredData: T[]
filters: FilterRule<T>[]
addFilter: (rule: FilterRule<T>) => void
removeFilter: (index: number) => void
clearFilters: () => void
updateFilter: (index: number, rule: FilterRule<T>) => void
}
export function useFilterableData<T>(data: T[]): UseFilterableDataReturn<T> {
const [filters, setFilters] = useState<FilterRule<T>[]>([])
const applyFilter = useCallback((item: T, rule: FilterRule<T>): 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<T>) => {
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<T>) => {
setFilters((current) =>
current.map((filter, i) => (i === index ? rule : filter))
)
}, [])
return {
filteredData,
filters,
addFilter,
removeFilter,
clearFilters,
updateFilter,
}
}

124
src/hooks/use-formatter.ts Normal file
View File

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

97
src/hooks/use-history.ts Normal file
View File

@@ -0,0 +1,97 @@
import { useState, useCallback, useEffect } from 'react'
export interface HistoryState<T> {
past: T[]
present: T
future: T[]
}
export interface UseHistoryReturn<T> {
state: T
setState: (newState: T | ((prev: T) => T)) => void
undo: () => void
redo: () => void
clear: () => void
canUndo: boolean
canRedo: boolean
history: HistoryState<T>
}
export function useHistory<T>(initialState: T, maxHistory = 50): UseHistoryReturn<T> {
const [history, setHistory] = useState<HistoryState<T>>({
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,
}
}

View File

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

View File

@@ -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<RecurringSchedule[]>([])
const addSchedule = useCallback((schedule: Omit<RecurringSchedule, 'id'>) => {
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<RecurringSchedule>) => {
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,
}
}

View File

@@ -0,0 +1,87 @@
import { useState, useCallback, useMemo } from 'react'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig<T> {
key: keyof T
direction: SortDirection
}
export interface UseSortableDataReturn<T> {
sortedData: T[]
sortConfig: SortConfig<T> | null
requestSort: (key: keyof T) => void
clearSort: () => void
}
export function useSortableData<T>(
data: T[],
defaultConfig?: SortConfig<T>
): UseSortableDataReturn<T> {
const [sortConfig, setSortConfig] = useState<SortConfig<T> | 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,
}
}

View File

@@ -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<T = any>(storageKey: string) {
const [templates = [], setTemplates] = useKV<Template[]>(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<Omit<Template, 'id' | 'createdDate'>>) => {
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,
}
}