mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Expand custom hook library, expand ui component library
This commit is contained in:
390
src/components/ui/NEW_COMPONENTS.md
Normal file
390
src/components/ui/NEW_COMPONENTS.md
Normal 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>
|
||||
)
|
||||
}
|
||||
```
|
||||
88
src/components/ui/activity-log.tsx
Normal file
88
src/components/ui/activity-log.tsx
Normal 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 }
|
||||
62
src/components/ui/description-list.tsx
Normal file
62
src/components/ui/description-list.tsx
Normal 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 }
|
||||
107
src/components/ui/export-button.tsx
Normal file
107
src/components/ui/export-button.tsx
Normal 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 }
|
||||
78
src/components/ui/filter-chips-bar.tsx
Normal file
78
src/components/ui/filter-chips-bar.tsx
Normal 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 }
|
||||
115
src/components/ui/notification-list.tsx
Normal file
115
src/components/ui/notification-list.tsx
Normal 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 }
|
||||
148
src/components/ui/pagination-buttons.tsx
Normal file
148
src/components/ui/pagination-buttons.tsx
Normal 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 }
|
||||
49
src/components/ui/quick-search.tsx
Normal file
49
src/components/ui/quick-search.tsx
Normal 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 }
|
||||
123
src/components/ui/simple-table.tsx
Normal file
123
src/components/ui/simple-table.tsx
Normal 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
|
||||
71
src/components/ui/status-indicator.tsx
Normal file
71
src/components/ui/status-indicator.tsx
Normal 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 }
|
||||
103
src/components/ui/stepper-simple.tsx
Normal file
103
src/components/ui/stepper-simple.tsx
Normal 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 }
|
||||
90
src/components/ui/timeline-vertical.tsx
Normal file
90
src/components/ui/timeline-vertical.tsx
Normal 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 }
|
||||
162
src/components/ui/wizard.tsx
Normal file
162
src/components/ui/wizard.tsx
Normal 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
285
src/hooks/NEW_HOOKS.md
Normal 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>
|
||||
}
|
||||
```
|
||||
@@ -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'
|
||||
|
||||
|
||||
162
src/hooks/use-approval-workflow.ts
Normal file
162
src/hooks/use-approval-workflow.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
85
src/hooks/use-audit-log.ts
Normal file
85
src/hooks/use-audit-log.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
174
src/hooks/use-compliance-check.ts
Normal file
174
src/hooks/use-compliance-check.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
93
src/hooks/use-data-export.ts
Normal file
93
src/hooks/use-data-export.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
114
src/hooks/use-filterable-data.ts
Normal file
114
src/hooks/use-filterable-data.ts
Normal 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
124
src/hooks/use-formatter.ts
Normal 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
97
src/hooks/use-history.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
96
src/hooks/use-rate-calculator.ts
Normal file
96
src/hooks/use-rate-calculator.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
128
src/hooks/use-recurring-schedule.ts
Normal file
128
src/hooks/use-recurring-schedule.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
87
src/hooks/use-sortable-data.ts
Normal file
87
src/hooks/use-sortable-data.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
114
src/hooks/use-template-manager.ts
Normal file
114
src/hooks/use-template-manager.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user