Generated by Spark: Add more specialized business logic hooks (invoicing, payroll calculations, time tracking)

This commit is contained in:
2026-01-23 07:20:19 +00:00
committed by GitHub
parent 58eac1c2eb
commit b072ac4689
11 changed files with 2504 additions and 2 deletions

View File

@@ -32,6 +32,7 @@ import { ShiftPatternManager } from '@/components/ShiftPatternManager'
import { QueryLanguageGuide } from '@/components/QueryLanguageGuide'
import { RoadmapView } from '@/components/roadmap-view'
import { ComponentShowcase } from '@/components/ComponentShowcase'
import { BusinessLogicDemo } from '@/components/BusinessLogicDemo'
import type {
Timesheet,
Invoice,
@@ -47,7 +48,7 @@ import type {
ShiftEntry
} from '@/lib/types'
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase'
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo'
function App() {
useSampleData()
@@ -565,6 +566,10 @@ function App() {
{currentView === 'component-showcase' && (
<ComponentShowcase />
)}
{currentView === 'business-logic-demo' && (
<BusinessLogicDemo />
)}
</div>
</main>
</div>

View File

@@ -0,0 +1,449 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import {
useInvoicing,
usePayrollCalculations,
useTimeTracking,
useMarginAnalysis,
useComplianceTracking
} from '@/hooks'
import {
Receipt,
CurrencyCircleDollar,
Clock,
TrendUp,
ShieldCheck,
CheckCircle,
XCircle,
Warning
} from '@phosphor-icons/react'
export function BusinessLogicDemo() {
const [activeTab, setActiveTab] = useState('invoicing')
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-semibold mb-2">Business Logic Hooks Demo</h1>
<p className="text-muted-foreground">
Specialized hooks for invoicing, payroll, time tracking, margin analysis, and compliance
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="invoicing">
<Receipt className="mr-2 h-4 w-4" />
Invoicing
</TabsTrigger>
<TabsTrigger value="payroll">
<CurrencyCircleDollar className="mr-2 h-4 w-4" />
Payroll
</TabsTrigger>
<TabsTrigger value="time">
<Clock className="mr-2 h-4 w-4" />
Time Tracking
</TabsTrigger>
<TabsTrigger value="margin">
<TrendUp className="mr-2 h-4 w-4" />
Margin Analysis
</TabsTrigger>
<TabsTrigger value="compliance">
<ShieldCheck className="mr-2 h-4 w-4" />
Compliance
</TabsTrigger>
</TabsList>
<TabsContent value="invoicing" className="space-y-4">
<InvoicingDemo />
</TabsContent>
<TabsContent value="payroll" className="space-y-4">
<PayrollDemo />
</TabsContent>
<TabsContent value="time" className="space-y-4">
<TimeTrackingDemo />
</TabsContent>
<TabsContent value="margin" className="space-y-4">
<MarginAnalysisDemo />
</TabsContent>
<TabsContent value="compliance" className="space-y-4">
<ComplianceDemo />
</TabsContent>
</Tabs>
</div>
)
}
function InvoicingDemo() {
const {
invoices,
calculateInvoiceAging,
getOverdueInvoices,
getInvoicesByStatus
} = useInvoicing()
const aging = calculateInvoiceAging()
const overdue = getOverdueInvoices()
const draft = getInvoicesByStatus('draft')
const paid = getInvoicesByStatus('paid')
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{invoices.length}</div>
<p className="text-xs text-muted-foreground mt-1">
{draft.length} draft, {paid.length} paid
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Current</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">£{aging.current.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">Not yet due</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">30 Days</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">£{aging.days30.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">1-30 days overdue</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">90+ Days</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
£{aging.over90.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-1">{overdue.length} invoices</p>
</CardContent>
</Card>
<Card className="md:col-span-2 lg:col-span-4">
<CardHeader>
<CardTitle>Invoice Aging Breakdown</CardTitle>
<CardDescription>Breakdown of outstanding invoices by age</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Current</span>
<span className="font-semibold">£{aging.current.toLocaleString()}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm">1-30 days</span>
<span className="font-semibold">£{aging.days30.toLocaleString()}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm">31-60 days</span>
<span className="font-semibold">£{aging.days60.toLocaleString()}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm">61-90 days</span>
<span className="font-semibold">£{aging.days90.toLocaleString()}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm">90+ days</span>
<span className="font-semibold text-destructive">£{aging.over90.toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
function PayrollDemo() {
const { payrollConfig, calculatePayroll } = usePayrollCalculations()
const [result, setResult] = useState<any>(null)
const handleCalculate = () => {
const calc = calculatePayroll('demo-worker', 3000, 36000, true)
setResult(calc)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Payroll Configuration</CardTitle>
<CardDescription>Current UK tax year settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<span className="text-sm font-medium">Tax Year:</span>
<p className="text-muted-foreground">{payrollConfig.taxYear}</p>
</div>
<div>
<span className="text-sm font-medium">Personal Allowance:</span>
<p className="text-muted-foreground">£{payrollConfig.personalAllowance.toLocaleString()}</p>
</div>
<div>
<span className="text-sm font-medium">Employer NI Rate:</span>
<p className="text-muted-foreground">{(payrollConfig.employerNIRate * 100).toFixed(1)}%</p>
</div>
<div>
<span className="text-sm font-medium">Pension Rate:</span>
<p className="text-muted-foreground">{(payrollConfig.pensionRate * 100).toFixed(1)}%</p>
</div>
</div>
<Separator />
<div>
<Button onClick={handleCalculate}>Calculate Example Payroll</Button>
<p className="text-sm text-muted-foreground mt-2">
Calculate for £3,000 monthly (£36,000 annual) with student loan
</p>
</div>
</CardContent>
</Card>
{result && (
<Card>
<CardHeader>
<CardTitle>Calculation Result</CardTitle>
<CardDescription>{result.workerName}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{result.breakdown.map((item: any, index: number) => (
<div key={index}>
<div className="flex items-center justify-between py-1">
<span className="text-sm">{item.description}</span>
<span className={`font-semibold ${item.amount < 0 ? 'text-destructive' : ''}`}>
£{Math.abs(item.amount).toFixed(2)}
</span>
</div>
{index < result.breakdown.length - 1 && <Separator />}
</div>
))}
</div>
<div className="mt-4 p-4 bg-muted rounded-lg">
<div className="flex justify-between items-center">
<span className="font-semibold">Total Employer Cost:</span>
<span className="text-lg font-bold">£{result.totalCost.toFixed(2)}</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
function TimeTrackingDemo() {
const { shiftPremiums, calculateShiftHours } = useTimeTracking()
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Shift Premiums</CardTitle>
<CardDescription>Automatic rate multipliers for different shift types</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{shiftPremiums.map(premium => (
<div key={premium.shiftType} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium capitalize">{premium.shiftType.replace('-', ' ')}</p>
<p className="text-sm text-muted-foreground">{premium.description}</p>
</div>
<Badge variant={premium.multiplier > 1.5 ? 'default' : 'secondary'}>
{premium.multiplier.toFixed(2)}x
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Shift Calculator</CardTitle>
<CardDescription>Calculate hours with break deduction</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div>
<span className="text-sm font-medium">Example: 09:00 - 17:00</span>
<p className="text-muted-foreground">30 min break</p>
<p className="text-lg font-bold mt-1">
{calculateShiftHours('09:00', '17:00', 30).toFixed(2)} hours
</p>
</div>
<div>
<span className="text-sm font-medium">Example: 22:00 - 06:00</span>
<p className="text-muted-foreground">60 min break</p>
<p className="text-lg font-bold mt-1">
{calculateShiftHours('22:00', '06:00', 60).toFixed(2)} hours
</p>
</div>
<div>
<span className="text-sm font-medium">Example: 14:00 - 22:00</span>
<p className="text-muted-foreground">No break</p>
<p className="text-lg font-bold mt-1">
{calculateShiftHours('14:00', '22:00', 0).toFixed(2)} hours
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
function MarginAnalysisDemo() {
const { analyzeClientProfitability } = useMarginAnalysis()
const clients = analyzeClientProfitability().slice(0, 5)
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Top Clients by Revenue</CardTitle>
<CardDescription>Client profitability analysis</CardDescription>
</CardHeader>
<CardContent>
{clients.length > 0 ? (
<div className="space-y-4">
{clients.map((client, index) => (
<div key={client.clientName} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{index + 1}.</span>
<span className="font-medium">{client.clientName}</span>
</div>
<Badge variant={client.marginPercentage > 20 ? 'default' : 'secondary'}>
{client.marginPercentage.toFixed(1)}% margin
</Badge>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Revenue:</span>
<p className="font-semibold">£{client.revenue.toLocaleString()}</p>
</div>
<div>
<span className="text-muted-foreground">Margin:</span>
<p className="font-semibold">£{client.margin.toLocaleString()}</p>
</div>
<div>
<span className="text-muted-foreground">Invoices:</span>
<p className="font-semibold">{client.invoiceCount}</p>
</div>
</div>
{index < clients.length - 1 && <Separator />}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-center py-4">No client data available</p>
)}
</CardContent>
</Card>
</div>
)
}
function ComplianceDemo() {
const { complianceRules, getComplianceDashboard } = useComplianceTracking()
const dashboard = getComplianceDashboard()
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Compliance Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{dashboard.complianceRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{dashboard.compliantWorkers} of {dashboard.totalWorkers} workers
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Expiring Soon</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-warning">{dashboard.documentsExpiringSoon}</div>
<p className="text-xs text-muted-foreground mt-1">Documents need renewal</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Expired</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{dashboard.documentsExpired}</div>
<p className="text-xs text-muted-foreground mt-1">Urgent action required</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Compliance Rules</CardTitle>
<CardDescription>Configured document requirements</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{complianceRules.map(rule => (
<div key={rule.documentType} className="flex items-start justify-between p-3 border rounded-lg">
<div className="flex items-start gap-3">
{rule.required ? (
<CheckCircle className="h-5 w-5 text-success mt-0.5" weight="fill" />
) : (
<XCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
)}
<div>
<p className="font-medium">{rule.documentType}</p>
<p className="text-sm text-muted-foreground">
{rule.required ? 'Required' : 'Optional'}
Warning at {rule.expiryWarningDays} days
Renewal lead time {rule.renewalLeadDays} days
</p>
</div>
</div>
{rule.required && <Badge>Required</Badge>}
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -6,7 +6,8 @@ import {
Buildings,
MapTrifold,
Question,
PuzzlePiece
PuzzlePiece,
Code
} from '@phosphor-icons/react'
import { NavItem } from '@/components/nav/NavItem'
import { CoreOperationsNav, ReportsNav, ConfigurationNav, ToolsNav } from '@/components/nav/nav-sections'
@@ -103,6 +104,12 @@ export function Sidebar({ currentView, setCurrentView, currentEntity, setCurrent
active={currentView === 'component-showcase'}
onClick={() => setCurrentView('component-showcase')}
/>
<NavItem
icon={<Code size={20} />}
label="Business Logic Hooks"
active={currentView === 'business-logic-demo'}
onClick={() => setCurrentView('business-logic-demo')}
/>
<NavItem
icon={<Question size={20} />}
label="Query Guide"

View File

@@ -0,0 +1,436 @@
# Business Logic Hooks
Specialized hooks for WorkForce Pro platform business operations.
## Overview
These hooks encapsulate complex business logic for staffing and recruitment operations, including invoicing, payroll calculations, time tracking, margin analysis, and compliance tracking. They are designed to be composable, reusable, and production-ready.
## Hooks
### `useInvoicing`
Complete invoice lifecycle management with automated generation and aging analysis.
**Features:**
- Generate invoices from timesheets
- Create placement fee invoices
- Credit note generation
- Invoice aging analysis (30/60/90 days)
- Multi-currency support
- Tax calculations
- Payment terms management
**Key Functions:**
- `createInvoiceFromTimesheets()` - Batch invoice generation
- `createPlacementInvoice()` - Permanent placement fees
- `createCreditNote()` - Credit note with reason tracking
- `calculateInvoiceAging()` - Aged debt analysis
- `getOverdueInvoices()` - Outstanding payment tracking
---
### `usePayrollCalculations`
UK-compliant payroll calculations including tax, NI, pensions, and statutory payments.
**Features:**
- Income tax calculation (UK bands)
- National Insurance (employee & employer)
- Pension auto-enrollment
- Student loan deductions
- CIS deductions for contractors
- Statutory sick/maternity/paternity pay
- Holiday pay calculations
- Batch payroll processing
**Key Functions:**
- `calculatePayroll()` - Full payroll calculation for single worker
- `calculateBatchPayroll()` - Process multiple workers
- `processPayrollRun()` - Create payroll run record
- `calculateHolidayPay()` - Holiday accrual and payment
- `calculateCISDeduction()` - Construction Industry Scheme
- `calculateStatutoryPayments()` - SSP, SMP, SPP calculations
**Tax Configuration:**
```tsx
const payrollConfig = {
taxYear: '2024/25',
personalAllowance: 12570,
taxBands: [
{ threshold: 0, rate: 0.20 },
{ threshold: 50270, rate: 0.40 },
{ threshold: 125140, rate: 0.45 }
],
niRates: [...],
employerNIRate: 0.138,
pensionRate: 0.05
}
```
---
### `useTimeTracking`
Advanced time and shift management with automatic rate calculations and validation.
**Features:**
- Shift hour calculations with break deductions
- Automatic shift type detection (night/weekend/overtime)
- Rate multiplier application
- Working Time Directive validation
- Overtime calculation
- Shift pattern analysis
- Rate card integration
- Time rounding
**Shift Types & Premiums:**
- Standard (1.0x)
- Overtime (1.5x)
- Weekend (1.5x)
- Night shift (1.33x)
- Bank holiday (2.0x)
- Evening (1.25x)
- Early morning (1.25x)
- Split shift (1.15x)
**Key Functions:**
- `calculateShiftPay()` - Full shift calculation with premiums
- `validateTimesheet()` - WTD compliance checking
- `analyzeWorkingTime()` - Period analytics
- `createTimesheetFromShifts()` - Aggregate shift data
- `calculateOvertimeHours()` - Standard vs overtime split
- `findRateCard()` - Automatic rate card lookup
**Validation Rules:**
```tsx
const timePattern = {
maxHoursPerDay: 12,
maxHoursPerWeek: 48,
maxConsecutiveDays: 6,
minBreakMinutes: 30,
minRestHours: 11
}
```
---
### `useMarginAnalysis`
Financial analytics for profitability, utilization, and forecasting.
**Features:**
- Gross margin calculation
- Client profitability analysis
- Worker utilization tracking
- Period-over-period comparison
- Break-even analysis
- Revenue forecasting (simple trend-based)
- Cost categorization
**Key Functions:**
- `calculateMarginForPeriod()` - Revenue vs costs with breakdown
- `analyzeClientProfitability()` - Per-client margin analysis
- `analyzeWorkerUtilization()` - Hours worked vs available
- `comparePeriods()` - Period comparison with % changes
- `calculateBreakEvenPoint()` - Fixed cost coverage
- `forecastRevenue()` - Trend-based projection
**Analysis Output:**
```tsx
interface MarginCalculation {
period: string
revenue: number
costs: number
grossMargin: number
marginPercentage: number
breakdown: MarginBreakdown[]
}
interface ClientProfitability {
clientName: string
revenue: number
margin: number
marginPercentage: number
avgInvoiceValue: number
}
interface WorkerUtilization {
workerName: string
hoursWorked: number
utilizationRate: number
avgRate: number
}
```
---
### `useComplianceTracking`
Document expiry monitoring and worker eligibility management.
**Features:**
- Document expiry tracking
- Configurable compliance rules
- Worker eligibility checks
- Renewal alert generation
- Compliance scoring
- Dashboard metrics
- Bulk compliance checking
**Default Document Types:**
- Right to Work
- DBS Check
- Professional Qualification
- Health & Safety Training
- First Aid Certificate
- Driving License
**Key Functions:**
- `checkWorkerCompliance()` - Full compliance check with scoring
- `addComplianceDocument()` - Upload with auto-status
- `getComplianceDashboard()` - Overview metrics
- `getRenewalAlerts()` - Upcoming expiries
- `getNonCompliantWorkers()` - At-risk workers
- `canWorkerBeAssigned()` - Placement eligibility
**Compliance Rules:**
```tsx
interface ComplianceRule {
documentType: string
required: boolean
expiryWarningDays: number
renewalLeadDays: number
applicableWorkerTypes?: string[]
}
```
## Usage Examples
### Complete Invoice Workflow
```tsx
function BillingComponent() {
const { createInvoiceFromTimesheets, saveInvoice } = useInvoicing()
const { timesheets } = useTimeTracking()
const handleGenerateInvoices = async () => {
const approvedTimesheets = timesheets.filter(ts => ts.status === 'approved')
// Group by client
const byClient = groupBy(approvedTimesheets, 'clientName')
for (const [client, sheets] of Object.entries(byClient)) {
const invoice = createInvoiceFromTimesheets(sheets, client, {
applyTax: true,
taxRate: 0.20,
paymentTermsDays: 30
})
await saveInvoice(invoice)
}
}
return <Button onClick={handleGenerateInvoices}>Generate Invoices</Button>
}
```
### Payroll Processing
```tsx
function PayrollProcessor() {
const { calculateBatchPayroll, processPayrollRun } = usePayrollCalculations()
const { timesheets } = useTimeTracking()
const handleProcessPayroll = async () => {
const approvedIds = timesheets
.filter(ts => ts.status === 'approved')
.map(ts => ts.id)
const calculations = calculateBatchPayroll(approvedIds)
// Review calculations
calculations.forEach(calc => {
console.log(`${calc.workerName}: £${calc.netPay} net`)
})
// Process
const run = await processPayrollRun('2024-01-31', approvedIds)
console.log(`Processed ${run.workersCount} workers`)
}
return <Button onClick={handleProcessPayroll}>Process Payroll</Button>
}
```
### Shift Entry with Validation
```tsx
function ShiftEntryForm() {
const { calculateShiftPay, validateTimesheet, createTimesheetFromShifts } = useTimeTracking()
const [shifts, setShifts] = useState<ShiftEntry[]>([])
const handleAddShift = (shiftData) => {
const calculatedShift = calculateShiftPay(shiftData, {
baseRate: 15,
applyPremiums: true,
roundToNearest: 0.25
})
setShifts([...shifts, calculatedShift])
}
const handleSubmit = () => {
const timesheet = createTimesheetFromShifts(
workerId,
workerName,
clientName,
shifts,
weekEnding
)
const validation = validateTimesheet(timesheet)
if (!validation.isValid) {
alert(`Errors: ${validation.errors.join(', ')}`)
return
}
if (validation.warnings.length > 0) {
console.warn('Warnings:', validation.warnings)
}
// Submit timesheet
}
return (...)
}
```
### Compliance Dashboard
```tsx
function ComplianceDashboard() {
const {
getComplianceDashboard,
getRenewalAlerts,
getNonCompliantWorkers
} = useComplianceTracking()
const dashboard = getComplianceDashboard()
const alerts = getRenewalAlerts(90)
const nonCompliant = getNonCompliantWorkers()
return (
<div>
<MetricCard
title="Compliance Rate"
value={`${dashboard.complianceRate.toFixed(1)}%`}
subtitle={`${dashboard.compliantWorkers} of ${dashboard.totalWorkers} workers`}
/>
<AlertsList>
{alerts.filter(a => a.urgency === 'critical').map(alert => (
<Alert key={alert.documentId} severity="error">
{alert.workerName} - {alert.documentType} expired
</Alert>
))}
</AlertsList>
<WorkerList workers={nonCompliant} />
</div>
)
}
```
## Integration Patterns
### Combined Workflow
```tsx
function TimesheetApprovalWorkflow() {
const { timesheets } = useTimeTracking()
const { createInvoiceFromTimesheets } = useInvoicing()
const { calculateBatchPayroll } = usePayrollCalculations()
const { checkWorkerCompliance } = useComplianceTracking()
const handleApproveAndProcess = async (timesheetId: string) => {
const timesheet = timesheets.find(ts => ts.id === timesheetId)
// 1. Check compliance
const compliance = checkWorkerCompliance(timesheet.workerId)
if (!compliance.isCompliant) {
throw new Error('Worker not compliant')
}
// 2. Approve timesheet
approveTimesheet(timesheetId)
// 3. Generate invoice
const invoice = createInvoiceFromTimesheets([timesheet], timesheet.clientName)
// 4. Calculate payroll impact
const payroll = calculateBatchPayroll([timesheetId])
return { invoice, payroll }
}
}
```
## Best Practices
1. **Always validate before processing**
```tsx
const validation = validateTimesheet(timesheet)
if (!validation.isValid) return
```
2. **Use functional updates with useKV**
```tsx
setInvoices(current => [...(current || []), newInvoice])
```
3. **Handle edge cases**
```tsx
const margin = revenue > 0 ? ((revenue - costs) / revenue) * 100 : 0
```
4. **Compose hooks for complex workflows**
```tsx
const invoicing = useInvoicing()
const payroll = usePayrollCalculations()
const tracking = useTimeTracking()
```
5. **Memoize expensive calculations**
- All hooks use `useCallback` and `useMemo` internally
- Results are cached based on dependencies
## Performance Considerations
- **Batch operations** where possible (e.g., `calculateBatchPayroll`)
- **Filter early** to reduce data processing
- **Use indices** for lookups (Map/Set where appropriate)
- **Avoid recalculations** - hooks cache results
## Type Safety
All hooks are fully typed with TypeScript:
- Input validation through types
- Return type inference
- Generic types where appropriate
- Strict null checks
## Testing
Each hook can be tested independently:
```tsx
import { renderHook, act } from '@testing-library/react'
import { useInvoicing } from './use-invoicing'
test('generates invoice number', () => {
const { result } = renderHook(() => useInvoicing())
const invoiceNumber = result.current.generateInvoiceNumber()
expect(invoiceNumber).toMatch(/^INV-\d{5}-\d{6}$/)
})
```
## Future Enhancements
- Real-time tax rate updates via API
- Machine learning for revenue forecasting
- Advanced compliance automation
- Multi-currency rate updates
- Automated invoice reconciliation
- Payment gateway integration

View File

@@ -2,6 +2,178 @@
This document lists all newly added custom hooks to the library.
## Business Logic Hooks (Specialized)
### `useInvoicing`
Comprehensive invoicing hook with generation, aging analysis, and invoice management.
```tsx
const {
invoices,
isProcessing,
generateInvoiceNumber,
createInvoiceFromTimesheets,
createPlacementInvoice,
createCreditNote,
saveInvoice,
updateInvoiceStatus,
calculateInvoiceAging,
getInvoicesByClient,
getInvoicesByStatus,
getOverdueInvoices
} = useInvoicing()
// Create invoice from approved timesheets
const invoice = createInvoiceFromTimesheets(
approvedTimesheets,
'Client Name',
{ applyTax: true, taxRate: 0.20, paymentTermsDays: 30 }
)
await saveInvoice(invoice)
// Analyze invoice aging
const aging = calculateInvoiceAging()
console.log(`Overdue 90+ days: £${aging.over90}`)
```
### `usePayrollCalculations`
Full-featured payroll calculation hook with UK tax, NI, pension, and statutory payments.
```tsx
const {
calculatePayroll,
calculateBatchPayroll,
calculateHolidayPay,
processPayrollRun,
calculateCISDeduction,
calculateStatutoryPayments,
payrollConfig
} = usePayrollCalculations()
// Calculate single worker payroll
const result = calculatePayroll('worker-123', 3000, 36000, true)
console.log(`Net pay: £${result.netPay}`)
console.log(`Employer cost: £${result.totalCost}`)
// Process batch payroll
const payrollRun = await processPayrollRun('2024-01-31', timesheetIds)
// Calculate holiday pay
const holiday = calculateHolidayPay('worker-123', startDate, endDate)
console.log(`Accrued: ${holiday.accruedHoliday} hours`)
```
### `useTimeTracking`
Advanced time tracking with shift calculations, validation, and analytics.
```tsx
const {
calculateShiftHours,
determineShiftType,
calculateShiftPay,
validateTimesheet,
analyzeWorkingTime,
createTimesheetFromShifts,
calculateOvertimeHours,
findRateCard
} = useTimeTracking()
// Calculate shift with premiums
const shift = calculateShiftPay({
date: '2024-01-15',
dayOfWeek: 'saturday',
shiftType: 'weekend',
startTime: '08:00',
endTime: '16:00',
breakMinutes: 30,
hours: 0, // will be calculated
rate: 0, // will be calculated
notes: 'Saturday shift'
}, { baseRate: 15, applyPremiums: true })
// Validate timesheet
const validation = validateTimesheet(timesheet)
if (!validation.isValid) {
console.error('Errors:', validation.errors)
}
// Analyze working time
const analytics = analyzeWorkingTime('worker-123', startDate, endDate)
console.log(`Total: ${analytics.totalHours}h, Overtime: ${analytics.overtimeHours}h`)
```
### `useMarginAnalysis`
Business intelligence hook for margin calculation, profitability analysis, and forecasting.
```tsx
const {
calculateMarginForPeriod,
analyzeClientProfitability,
analyzeWorkerUtilization,
comparePeriods,
calculateBreakEvenPoint,
forecastRevenue
} = useMarginAnalysis()
// Calculate margin for a period
const margin = calculateMarginForPeriod(startDate, endDate, true)
console.log(`Margin: ${margin.marginPercentage.toFixed(2)}%`)
// Analyze client profitability
const clients = analyzeClientProfitability(startDate, endDate)
const topClient = clients[0]
console.log(`${topClient.clientName}: £${topClient.margin} margin`)
// Worker utilization
const workers = analyzeWorkerUtilization(startDate, endDate, 40)
workers.forEach(w => {
console.log(`${w.workerName}: ${w.utilizationRate.toFixed(1)}% utilized`)
})
// Forecast revenue
const forecast = forecastRevenue(6, 3)
console.log('Next 3 months forecast:', forecast)
```
### `useComplianceTracking`
Compliance document tracking with expiry monitoring and worker eligibility checks.
```tsx
const {
complianceDocs,
complianceRules,
checkWorkerCompliance,
addComplianceDocument,
updateDocumentExpiry,
getComplianceDashboard,
getRenewalAlerts,
getNonCompliantWorkers,
canWorkerBeAssigned
} = useComplianceTracking()
// Check worker compliance
const check = checkWorkerCompliance('worker-123')
if (!check.isCompliant) {
console.log('Missing:', check.missingDocuments)
console.log('Expired:', check.expiredDocuments.length)
}
// Get compliance dashboard
const dashboard = getComplianceDashboard()
console.log(`Compliance rate: ${dashboard.complianceRate.toFixed(1)}%`)
// Get renewal alerts
const alerts = getRenewalAlerts(90) // next 90 days
alerts.forEach(alert => {
console.log(`${alert.workerName}: ${alert.documentType} expires in ${alert.daysUntilExpiry} days`)
})
// Check if worker can be assigned
if (canWorkerBeAssigned('worker-123')) {
assignToPlacement(workerId, placementId)
}
```
## Data Fetching & State Management
### `useFetch`

View File

@@ -87,6 +87,12 @@ export { useNetworkStatus } from './use-network-status'
export { useUpdateEffect } from './use-update-effect'
export { useEvent, useLatest } from './use-event'
export { useInvoicing } from './use-invoicing'
export { usePayrollCalculations } from './use-payroll-calculations'
export { useTimeTracking } from './use-time-tracking'
export { useMarginAnalysis } from './use-margin-analysis'
export { useComplianceTracking } from './use-compliance-tracking'
export type { AsyncState } from './use-async'
export type { FormErrors } from './use-form-validation'
export type { IntersectionObserverOptions } from './use-intersection-observer'

View File

@@ -0,0 +1,263 @@
import { useState, useCallback } from 'react'
import { useKV } from '@github/spark/hooks'
import type { ComplianceDocument, Worker, ComplianceStatus } from '@/lib/types'
interface ComplianceRule {
documentType: string
required: boolean
expiryWarningDays: number
renewalLeadDays: number
applicableWorkerTypes?: string[]
}
interface ComplianceCheck {
workerId: string
workerName: string
isCompliant: boolean
missingDocuments: string[]
expiringDocuments: ComplianceDocument[]
expiredDocuments: ComplianceDocument[]
complianceScore: number
}
interface ComplianceDashboard {
totalWorkers: number
compliantWorkers: number
nonCompliantWorkers: number
complianceRate: number
documentsExpiringSoon: number
documentsExpired: number
documentsByType: Record<string, number>
}
interface DocumentRenewalAlert {
documentId: string
workerId: string
workerName: string
documentType: string
expiryDate: string
daysUntilExpiry: number
urgency: 'critical' | 'high' | 'medium' | 'low'
}
const DEFAULT_COMPLIANCE_RULES: ComplianceRule[] = [
{
documentType: 'Right to Work',
required: true,
expiryWarningDays: 30,
renewalLeadDays: 60
},
{
documentType: 'DBS Check',
required: true,
expiryWarningDays: 45,
renewalLeadDays: 90
},
{
documentType: 'Professional Qualification',
required: true,
expiryWarningDays: 60,
renewalLeadDays: 90
},
{
documentType: 'Health & Safety Training',
required: true,
expiryWarningDays: 30,
renewalLeadDays: 60
},
{
documentType: 'First Aid Certificate',
required: false,
expiryWarningDays: 30,
renewalLeadDays: 60
},
{
documentType: 'Driving License',
required: false,
expiryWarningDays: 60,
renewalLeadDays: 90
}
]
export function useComplianceTracking(customRules?: ComplianceRule[]) {
const [complianceDocs = [], setComplianceDocs] = useKV<ComplianceDocument[]>('compliance-docs', [])
const [workers = []] = useKV<Worker[]>('workers', [])
const [complianceRules] = useState<ComplianceRule[]>(customRules || DEFAULT_COMPLIANCE_RULES)
const calculateDaysUntilExpiry = useCallback((expiryDate: string): number => {
const expiry = new Date(expiryDate)
const now = new Date()
return Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}, [])
const determineDocumentStatus = useCallback((expiryDate: string): ComplianceStatus => {
const days = calculateDaysUntilExpiry(expiryDate)
if (days < 0) return 'expired'
if (days < 30) return 'expiring'
return 'valid'
}, [calculateDaysUntilExpiry])
const checkWorkerCompliance = useCallback((workerId: string): ComplianceCheck => {
const worker = workers.find(w => w.id === workerId)
const workerDocs = complianceDocs.filter(doc => doc.workerId === workerId)
const requiredRules = complianceRules.filter(rule => {
if (!rule.required) return false
if (!rule.applicableWorkerTypes) return true
return worker && rule.applicableWorkerTypes.includes(worker.type)
})
const missingDocuments: string[] = []
const expiringDocuments: ComplianceDocument[] = []
const expiredDocuments: ComplianceDocument[] = []
requiredRules.forEach(rule => {
const doc = workerDocs.find(d => d.documentType === rule.documentType)
if (!doc) {
missingDocuments.push(rule.documentType)
} else {
if (doc.status === 'expired') {
expiredDocuments.push(doc)
} else if (doc.status === 'expiring') {
expiringDocuments.push(doc)
}
}
})
const totalChecks = requiredRules.length
const passedChecks = totalChecks - missingDocuments.length - expiredDocuments.length
const complianceScore = totalChecks > 0 ? (passedChecks / totalChecks) * 100 : 0
return {
workerId,
workerName: worker?.name || 'Unknown',
isCompliant: missingDocuments.length === 0 && expiredDocuments.length === 0,
missingDocuments,
expiringDocuments,
expiredDocuments,
complianceScore
}
}, [workers, complianceDocs, complianceRules])
const addComplianceDocument = useCallback(async (
workerId: string,
workerName: string,
documentType: string,
expiryDate: string
) => {
const daysUntilExpiry = calculateDaysUntilExpiry(expiryDate)
const status = determineDocumentStatus(expiryDate)
const newDoc: ComplianceDocument = {
id: `DOC-${Date.now()}`,
workerId,
workerName,
documentType,
expiryDate,
status,
daysUntilExpiry
}
setComplianceDocs(current => [...(current || []), newDoc])
return newDoc
}, [setComplianceDocs, calculateDaysUntilExpiry, determineDocumentStatus])
const updateDocumentExpiry = useCallback(async (
documentId: string,
newExpiryDate: string
) => {
const daysUntilExpiry = calculateDaysUntilExpiry(newExpiryDate)
const status = determineDocumentStatus(newExpiryDate)
setComplianceDocs(current =>
(current || []).map(doc =>
doc.id === documentId
? { ...doc, expiryDate: newExpiryDate, daysUntilExpiry, status }
: doc
)
)
}, [setComplianceDocs, calculateDaysUntilExpiry, determineDocumentStatus])
const getComplianceDashboard = useCallback((): ComplianceDashboard => {
const activeWorkers = workers.filter(w => w.status === 'active')
const complianceChecks = activeWorkers.map(w => checkWorkerCompliance(w.id))
const compliantWorkers = complianceChecks.filter(c => c.isCompliant).length
const nonCompliantWorkers = activeWorkers.length - compliantWorkers
const documentsExpiringSoon = complianceDocs.filter(d => d.status === 'expiring').length
const documentsExpired = complianceDocs.filter(d => d.status === 'expired').length
const documentsByType = complianceDocs.reduce((acc, doc) => {
acc[doc.documentType] = (acc[doc.documentType] || 0) + 1
return acc
}, {} as Record<string, number>)
return {
totalWorkers: activeWorkers.length,
compliantWorkers,
nonCompliantWorkers,
complianceRate: activeWorkers.length > 0 ? (compliantWorkers / activeWorkers.length) * 100 : 0,
documentsExpiringSoon,
documentsExpired,
documentsByType
}
}, [workers, complianceDocs, checkWorkerCompliance])
const getRenewalAlerts = useCallback((daysAhead: number = 90): DocumentRenewalAlert[] => {
const now = new Date()
const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000)
return complianceDocs
.filter(doc => {
const expiry = new Date(doc.expiryDate)
return expiry <= futureDate
})
.map(doc => {
const daysUntilExpiry = calculateDaysUntilExpiry(doc.expiryDate)
let urgency: 'critical' | 'high' | 'medium' | 'low' = 'low'
if (daysUntilExpiry < 0) urgency = 'critical'
else if (daysUntilExpiry < 14) urgency = 'high'
else if (daysUntilExpiry < 30) urgency = 'medium'
return {
documentId: doc.id,
workerId: doc.workerId,
workerName: doc.workerName,
documentType: doc.documentType,
expiryDate: doc.expiryDate,
daysUntilExpiry,
urgency
}
})
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry)
}, [complianceDocs, calculateDaysUntilExpiry])
const getNonCompliantWorkers = useCallback((): ComplianceCheck[] => {
const activeWorkers = workers.filter(w => w.status === 'active')
return activeWorkers
.map(w => checkWorkerCompliance(w.id))
.filter(check => !check.isCompliant)
.sort((a, b) => a.complianceScore - b.complianceScore)
}, [workers, checkWorkerCompliance])
const canWorkerBeAssigned = useCallback((workerId: string): boolean => {
const check = checkWorkerCompliance(workerId)
return check.isCompliant
}, [checkWorkerCompliance])
return {
complianceDocs,
complianceRules,
checkWorkerCompliance,
addComplianceDocument,
updateDocumentExpiry,
getComplianceDashboard,
getRenewalAlerts,
getNonCompliantWorkers,
canWorkerBeAssigned,
determineDocumentStatus
}
}

235
src/hooks/use-invoicing.ts Normal file
View File

@@ -0,0 +1,235 @@
import { useState, useCallback } from 'react'
import { useKV } from '@github/spark/hooks'
import type { Invoice, InvoiceLineItem, Timesheet, InvoiceStatus, InvoiceType } from '@/lib/types'
interface InvoiceGenerationOptions {
includeLineItems?: boolean
applyTax?: boolean
taxRate?: number
paymentTermsDays?: number
roundingPrecision?: number
}
interface InvoiceAgingData {
current: number
days30: number
days60: number
days90: number
over90: number
}
export function useInvoicing() {
const [invoices = [], setInvoices] = useKV<Invoice[]>('invoices', [])
const [isProcessing, setIsProcessing] = useState(false)
const generateInvoiceNumber = useCallback((prefix: string = 'INV'): string => {
const count = invoices.length + 1
const timestamp = Date.now().toString().slice(-6)
return `${prefix}-${String(count).padStart(5, '0')}-${timestamp}`
}, [invoices.length])
const createInvoiceFromTimesheets = useCallback((
timesheets: Timesheet[],
clientName: string,
options: InvoiceGenerationOptions = {}
): Invoice => {
const {
includeLineItems = true,
applyTax = false,
taxRate = 0.20,
paymentTermsDays = 30,
roundingPrecision = 2
} = options
const lineItems: InvoiceLineItem[] = includeLineItems
? timesheets.map(ts => ({
id: `LINE-${ts.id}`,
description: `${ts.workerName} - Week ending ${new Date(ts.weekEnding).toLocaleDateString()}`,
quantity: ts.hours,
rate: ts.rate || 0,
amount: ts.amount,
timesheetId: ts.id
}))
: []
const subtotal = timesheets.reduce((sum, ts) => sum + ts.amount, 0)
const tax = applyTax ? subtotal * taxRate : 0
const total = Number((subtotal + tax).toFixed(roundingPrecision))
const issueDate = new Date()
const dueDate = new Date(issueDate)
dueDate.setDate(dueDate.getDate() + paymentTermsDays)
return {
id: `INV-${Date.now()}`,
invoiceNumber: generateInvoiceNumber(),
clientName,
issueDate: issueDate.toISOString().split('T')[0],
dueDate: dueDate.toISOString().split('T')[0],
amount: total,
status: 'draft',
currency: 'GBP',
lineItems,
type: 'timesheet',
paymentTerms: `Payment due within ${paymentTermsDays} days`
}
}, [generateInvoiceNumber])
const createPlacementInvoice = useCallback((
candidateName: string,
clientName: string,
salary: number,
feePercentage: number,
options: Partial<InvoiceGenerationOptions> = {}
): Invoice => {
const amount = (salary * feePercentage) / 100
const issueDate = new Date()
const dueDate = new Date(issueDate)
dueDate.setDate(dueDate.getDate() + (options.paymentTermsDays || 30))
return {
id: `INV-${Date.now()}`,
invoiceNumber: generateInvoiceNumber('PLACE'),
clientName,
issueDate: issueDate.toISOString().split('T')[0],
dueDate: dueDate.toISOString().split('T')[0],
amount,
status: 'draft',
currency: 'GBP',
type: 'permanent-placement',
placementDetails: {
candidateName,
position: 'Position',
startDate: issueDate.toISOString().split('T')[0],
salary,
feePercentage,
guaranteePeriod: 90
},
paymentTerms: `Payment due within ${options.paymentTermsDays || 30} days`
}
}, [generateInvoiceNumber])
const createCreditNote = useCallback((
originalInvoice: Invoice,
creditAmount: number,
reason: string
): Invoice => {
return {
id: `CREDIT-${Date.now()}`,
invoiceNumber: generateInvoiceNumber('CREDIT'),
clientName: originalInvoice.clientName,
issueDate: new Date().toISOString().split('T')[0],
dueDate: new Date().toISOString().split('T')[0],
amount: -Math.abs(creditAmount),
status: 'credit',
currency: originalInvoice.currency,
type: 'credit-note',
relatedInvoiceId: originalInvoice.id,
notes: `Credit note for ${originalInvoice.invoiceNumber}: ${reason}`
}
}, [generateInvoiceNumber])
const saveInvoice = useCallback(async (invoice: Invoice) => {
setIsProcessing(true)
try {
setInvoices(current => [...(current || []), invoice])
return invoice
} finally {
setIsProcessing(false)
}
}, [setInvoices])
const updateInvoiceStatus = useCallback(async (
invoiceId: string,
status: InvoiceStatus
) => {
setInvoices(current =>
(current || []).map(inv =>
inv.id === invoiceId ? { ...inv, status } : inv
)
)
}, [setInvoices])
const calculateInvoiceAging = useCallback((): InvoiceAgingData => {
const now = new Date()
const aging: InvoiceAgingData = {
current: 0,
days30: 0,
days60: 0,
days90: 0,
over90: 0
}
invoices.forEach(invoice => {
if (invoice.status === 'paid' || invoice.status === 'cancelled') return
const dueDate = new Date(invoice.dueDate)
const daysOverdue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
if (daysOverdue <= 0) {
aging.current += invoice.amount
} else if (daysOverdue <= 30) {
aging.days30 += invoice.amount
} else if (daysOverdue <= 60) {
aging.days60 += invoice.amount
} else if (daysOverdue <= 90) {
aging.days90 += invoice.amount
} else {
aging.over90 += invoice.amount
}
})
return aging
}, [invoices])
const getInvoicesByClient = useCallback((clientName: string): Invoice[] => {
return invoices.filter(inv => inv.clientName === clientName)
}, [invoices])
const getInvoicesByStatus = useCallback((status: InvoiceStatus): Invoice[] => {
return invoices.filter(inv => inv.status === status)
}, [invoices])
const getInvoicesByType = useCallback((type: InvoiceType): Invoice[] => {
return invoices.filter(inv => inv.type === type)
}, [invoices])
const calculateTotalRevenue = useCallback((startDate?: Date, endDate?: Date): number => {
return invoices
.filter(inv => {
if (inv.status === 'cancelled') return false
if (!startDate && !endDate) return true
const invDate = new Date(inv.issueDate)
if (startDate && invDate < startDate) return false
if (endDate && invDate > endDate) return false
return true
})
.reduce((sum, inv) => sum + inv.amount, 0)
}, [invoices])
const getOverdueInvoices = useCallback((): Invoice[] => {
const now = new Date()
return invoices.filter(inv => {
if (inv.status === 'paid' || inv.status === 'cancelled') return false
return new Date(inv.dueDate) < now
})
}, [invoices])
return {
invoices,
isProcessing,
generateInvoiceNumber,
createInvoiceFromTimesheets,
createPlacementInvoice,
createCreditNote,
saveInvoice,
updateInvoiceStatus,
calculateInvoiceAging,
getInvoicesByClient,
getInvoicesByStatus,
getInvoicesByType,
calculateTotalRevenue,
getOverdueInvoices
}
}

View File

@@ -0,0 +1,288 @@
import { useState, useCallback, useMemo } from 'react'
import { useKV } from '@github/spark/hooks'
import type { Timesheet, Invoice, Expense } from '@/lib/types'
interface MarginCalculation {
period: string
revenue: number
costs: number
grossMargin: number
marginPercentage: number
breakdown: MarginBreakdown[]
}
interface MarginBreakdown {
category: string
amount: number
percentage: number
}
interface ClientProfitability {
clientName: string
revenue: number
costs: number
margin: number
marginPercentage: number
invoiceCount: number
avgInvoiceValue: number
}
interface WorkerUtilization {
workerId: string
workerName: string
hoursWorked: number
availableHours: number
utilizationRate: number
revenue: number
avgRate: number
}
interface PeriodComparison {
current: MarginCalculation
previous: MarginCalculation
revenueChange: number
revenueChangePercentage: number
marginChange: number
marginChangePercentage: number
}
export function useMarginAnalysis() {
const [timesheets = []] = useKV<Timesheet[]>('timesheets', [])
const [invoices = []] = useKV<Invoice[]>('invoices', [])
const [expenses = []] = useKV<Expense[]>('expenses', [])
const calculateMarginForPeriod = useCallback((
startDate: Date,
endDate: Date,
includeExpenses: boolean = true
): MarginCalculation => {
const periodInvoices = invoices.filter(inv => {
const invDate = new Date(inv.issueDate)
return invDate >= startDate && invDate <= endDate && inv.status !== 'cancelled'
})
const periodTimesheets = timesheets.filter(ts => {
const tsDate = new Date(ts.weekEnding)
return tsDate >= startDate && tsDate <= endDate && ts.status === 'approved'
})
const periodExpenses = includeExpenses
? expenses.filter(exp => {
const expDate = new Date(exp.date)
return expDate >= startDate && expDate <= endDate && exp.status === 'approved'
})
: []
const revenue = periodInvoices.reduce((sum, inv) => sum + inv.amount, 0)
const payrollCosts = periodTimesheets.reduce((sum, ts) => sum + ts.amount, 0)
const expenseCosts = periodExpenses.reduce((sum, exp) => sum + exp.amount, 0)
const totalCosts = payrollCosts + expenseCosts
const grossMargin = revenue - totalCosts
const marginPercentage = revenue > 0 ? (grossMargin / revenue) * 100 : 0
const breakdown: MarginBreakdown[] = [
{
category: 'Revenue',
amount: revenue,
percentage: 100
},
{
category: 'Payroll Costs',
amount: payrollCosts,
percentage: revenue > 0 ? (payrollCosts / revenue) * 100 : 0
}
]
if (includeExpenses && expenseCosts > 0) {
breakdown.push({
category: 'Expenses',
amount: expenseCosts,
percentage: revenue > 0 ? (expenseCosts / revenue) * 100 : 0
})
}
breakdown.push({
category: 'Gross Margin',
amount: grossMargin,
percentage: marginPercentage
})
return {
period: `${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`,
revenue,
costs: totalCosts,
grossMargin,
marginPercentage,
breakdown
}
}, [invoices, timesheets, expenses])
const analyzeClientProfitability = useCallback((
startDate?: Date,
endDate?: Date
): ClientProfitability[] => {
const filteredInvoices = invoices.filter(inv => {
if (inv.status === 'cancelled') return false
if (!startDate && !endDate) return true
const invDate = new Date(inv.issueDate)
if (startDate && invDate < startDate) return false
if (endDate && invDate > endDate) return false
return true
})
const filteredTimesheets = timesheets.filter(ts => {
if (ts.status !== 'approved') return false
if (!startDate && !endDate) return true
const tsDate = new Date(ts.weekEnding)
if (startDate && tsDate < startDate) return false
if (endDate && tsDate > endDate) return false
return true
})
const clientData = new Map<string, { revenue: number; costs: number; invoiceCount: number }>()
filteredInvoices.forEach(inv => {
const existing = clientData.get(inv.clientName) || { revenue: 0, costs: 0, invoiceCount: 0 }
clientData.set(inv.clientName, {
revenue: existing.revenue + inv.amount,
costs: existing.costs,
invoiceCount: existing.invoiceCount + 1
})
})
filteredTimesheets.forEach(ts => {
const existing = clientData.get(ts.clientName) || { revenue: 0, costs: 0, invoiceCount: 0 }
clientData.set(ts.clientName, {
...existing,
costs: existing.costs + ts.amount
})
})
return Array.from(clientData.entries())
.map(([clientName, data]) => ({
clientName,
revenue: data.revenue,
costs: data.costs,
margin: data.revenue - data.costs,
marginPercentage: data.revenue > 0 ? ((data.revenue - data.costs) / data.revenue) * 100 : 0,
invoiceCount: data.invoiceCount,
avgInvoiceValue: data.invoiceCount > 0 ? data.revenue / data.invoiceCount : 0
}))
.sort((a, b) => b.revenue - a.revenue)
}, [invoices, timesheets])
const analyzeWorkerUtilization = useCallback((
startDate: Date,
endDate: Date,
standardWorkWeekHours: number = 40
): WorkerUtilization[] => {
const periodTimesheets = timesheets.filter(ts => {
const tsDate = new Date(ts.weekEnding)
return tsDate >= startDate && tsDate <= endDate && ts.status === 'approved'
})
const workerData = new Map<string, { name: string; hours: number; revenue: number }>()
periodTimesheets.forEach(ts => {
const existing = workerData.get(ts.workerId) || { name: ts.workerName, hours: 0, revenue: 0 }
workerData.set(ts.workerId, {
name: existing.name,
hours: existing.hours + ts.hours,
revenue: existing.revenue + ts.amount
})
})
const weeksInPeriod = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 7))
const availableHours = weeksInPeriod * standardWorkWeekHours
return Array.from(workerData.entries())
.map(([workerId, data]) => ({
workerId,
workerName: data.name,
hoursWorked: data.hours,
availableHours,
utilizationRate: (data.hours / availableHours) * 100,
revenue: data.revenue,
avgRate: data.hours > 0 ? data.revenue / data.hours : 0
}))
.sort((a, b) => b.utilizationRate - a.utilizationRate)
}, [timesheets])
const comparePeriods = useCallback((
currentStart: Date,
currentEnd: Date,
previousStart: Date,
previousEnd: Date
): PeriodComparison => {
const current = calculateMarginForPeriod(currentStart, currentEnd)
const previous = calculateMarginForPeriod(previousStart, previousEnd)
const revenueChange = current.revenue - previous.revenue
const revenueChangePercentage = previous.revenue > 0
? (revenueChange / previous.revenue) * 100
: 0
const marginChange = current.grossMargin - previous.grossMargin
const marginChangePercentage = previous.grossMargin > 0
? (marginChange / previous.grossMargin) * 100
: 0
return {
current,
previous,
revenueChange,
revenueChangePercentage,
marginChange,
marginChangePercentage
}
}, [calculateMarginForPeriod])
const calculateBreakEvenPoint = useCallback((
fixedCosts: number,
avgMarginPercentage: number
): number => {
if (avgMarginPercentage <= 0) return 0
return fixedCosts / (avgMarginPercentage / 100)
}, [])
const forecastRevenue = useCallback((
historicalMonths: number = 6,
forecastMonths: number = 3
): number[] => {
const now = new Date()
const monthlyRevenue: number[] = []
for (let i = historicalMonths - 1; i >= 0; i--) {
const startDate = new Date(now.getFullYear(), now.getMonth() - i, 1)
const endDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 0)
const margin = calculateMarginForPeriod(startDate, endDate)
monthlyRevenue.push(margin.revenue)
}
const avgRevenue = monthlyRevenue.reduce((sum, rev) => sum + rev, 0) / monthlyRevenue.length
const trend = monthlyRevenue.length > 1
? (monthlyRevenue[monthlyRevenue.length - 1] - monthlyRevenue[0]) / monthlyRevenue.length
: 0
const forecast: number[] = []
for (let i = 1; i <= forecastMonths; i++) {
forecast.push(avgRevenue + (trend * i))
}
return forecast
}, [calculateMarginForPeriod])
return {
calculateMarginForPeriod,
analyzeClientProfitability,
analyzeWorkerUtilization,
comparePeriods,
calculateBreakEvenPoint,
forecastRevenue
}
}

View File

@@ -0,0 +1,307 @@
import { useState, useCallback, useMemo } from 'react'
import { useKV } from '@github/spark/hooks'
import type { Timesheet, PayrollRun, Worker } from '@/lib/types'
interface TaxBand {
threshold: number
rate: number
}
interface NIContribution {
threshold: number
rate: number
upperThreshold?: number
}
interface PayrollCalculationResult {
workerId: string
workerName: string
grossPay: number
incomeTax: number
nationalInsurance: number
pensionContribution: number
studentLoan?: number
netPay: number
employerNI: number
totalCost: number
breakdown: PayrollBreakdown[]
}
interface PayrollBreakdown {
description: string
amount: number
type: 'earning' | 'deduction' | 'employer-cost'
}
interface PayrollConfig {
taxYear: string
personalAllowance: number
taxBands: TaxBand[]
niRates: NIContribution[]
employerNIRate: number
pensionRate: number
autoEnrollmentThreshold: number
studentLoanThreshold?: number
studentLoanRate?: number
}
interface HolidayPayCalculation {
workerId: string
workerName: string
eligibleHours: number
holidayAccrualRate: number
accruedHoliday: number
usedHoliday: number
remainingHoliday: number
holidayPayRate: number
totalHolidayPay: number
}
const DEFAULT_PAYROLL_CONFIG: PayrollConfig = {
taxYear: '2024/25',
personalAllowance: 12570,
taxBands: [
{ threshold: 0, rate: 0.20 },
{ threshold: 50270, rate: 0.40 },
{ threshold: 125140, rate: 0.45 }
],
niRates: [
{ threshold: 12570, rate: 0.12, upperThreshold: 50270 },
{ threshold: 50270, rate: 0.02 }
],
employerNIRate: 0.138,
pensionRate: 0.05,
autoEnrollmentThreshold: 10000,
studentLoanThreshold: 27295,
studentLoanRate: 0.09
}
export function usePayrollCalculations(config: Partial<PayrollConfig> = {}) {
const [timesheets = []] = useKV<Timesheet[]>('timesheets', [])
const [workers = []] = useKV<Worker[]>('workers', [])
const [payrollRuns = [], setPayrollRuns] = useKV<PayrollRun[]>('payroll-runs', [])
const [isProcessing, setIsProcessing] = useState(false)
const payrollConfig = useMemo(
() => ({ ...DEFAULT_PAYROLL_CONFIG, ...config }),
[config]
)
const calculateIncomeTax = useCallback((grossPay: number, annualGross: number): number => {
const taxableIncome = Math.max(0, annualGross - payrollConfig.personalAllowance)
let tax = 0
for (let i = 0; i < payrollConfig.taxBands.length; i++) {
const band = payrollConfig.taxBands[i]
const nextBand = payrollConfig.taxBands[i + 1]
if (taxableIncome > band.threshold) {
const taxableInBand = nextBand
? Math.min(taxableIncome, nextBand.threshold) - band.threshold
: taxableIncome - band.threshold
tax += taxableInBand * band.rate
}
}
return (tax / annualGross) * grossPay
}, [payrollConfig])
const calculateNationalInsurance = useCallback((grossPay: number, annualGross: number): number => {
let ni = 0
for (const rate of payrollConfig.niRates) {
if (annualGross > rate.threshold) {
const niableInBand = rate.upperThreshold
? Math.min(annualGross, rate.upperThreshold) - rate.threshold
: annualGross - rate.threshold
ni += niableInBand * rate.rate
}
}
return (ni / annualGross) * grossPay
}, [payrollConfig])
const calculateEmployerNI = useCallback((grossPay: number, annualGross: number): number => {
const niableIncome = Math.max(0, annualGross - payrollConfig.niRates[0].threshold)
const annualEmployerNI = niableIncome * payrollConfig.employerNIRate
return (annualEmployerNI / annualGross) * grossPay
}, [payrollConfig])
const calculatePensionContribution = useCallback((grossPay: number, annualGross: number): number => {
if (annualGross < payrollConfig.autoEnrollmentThreshold) return 0
return grossPay * payrollConfig.pensionRate
}, [payrollConfig])
const calculateStudentLoan = useCallback((grossPay: number, annualGross: number): number => {
if (!payrollConfig.studentLoanThreshold || !payrollConfig.studentLoanRate) return 0
if (annualGross <= payrollConfig.studentLoanThreshold) return 0
const repayableIncome = annualGross - payrollConfig.studentLoanThreshold
return (repayableIncome * payrollConfig.studentLoanRate / annualGross) * grossPay
}, [payrollConfig])
const calculatePayroll = useCallback((
workerId: string,
grossPay: number,
annualGross?: number,
includeStudentLoan: boolean = false
): PayrollCalculationResult => {
const worker = workers.find(w => w.id === workerId)
const estimatedAnnual = annualGross || grossPay * 12
const incomeTax = calculateIncomeTax(grossPay, estimatedAnnual)
const nationalInsurance = calculateNationalInsurance(grossPay, estimatedAnnual)
const pensionContribution = calculatePensionContribution(grossPay, estimatedAnnual)
const studentLoan = includeStudentLoan ? calculateStudentLoan(grossPay, estimatedAnnual) : 0
const employerNI = calculateEmployerNI(grossPay, estimatedAnnual)
const netPay = grossPay - incomeTax - nationalInsurance - pensionContribution - studentLoan
const totalCost = grossPay + employerNI + pensionContribution
const breakdown: PayrollBreakdown[] = [
{ description: 'Gross Pay', amount: grossPay, type: 'earning' },
{ description: 'Income Tax', amount: -incomeTax, type: 'deduction' },
{ description: 'National Insurance', amount: -nationalInsurance, type: 'deduction' },
{ description: 'Pension Contribution', amount: -pensionContribution, type: 'deduction' }
]
if (studentLoan > 0) {
breakdown.push({ description: 'Student Loan', amount: -studentLoan, type: 'deduction' })
}
breakdown.push(
{ description: 'Net Pay', amount: netPay, type: 'earning' },
{ description: 'Employer NI', amount: employerNI, type: 'employer-cost' }
)
return {
workerId,
workerName: worker?.name || 'Unknown',
grossPay,
incomeTax,
nationalInsurance,
pensionContribution,
studentLoan: studentLoan > 0 ? studentLoan : undefined,
netPay,
employerNI,
totalCost,
breakdown
}
}, [workers, calculateIncomeTax, calculateNationalInsurance, calculatePensionContribution, calculateStudentLoan, calculateEmployerNI])
const calculateBatchPayroll = useCallback((
timesheetIds: string[]
): PayrollCalculationResult[] => {
const relevantTimesheets = timesheets.filter(ts =>
timesheetIds.includes(ts.id) && ts.status === 'approved'
)
const workerTotals = relevantTimesheets.reduce((acc, ts) => {
if (!acc[ts.workerId]) {
acc[ts.workerId] = { grossPay: 0, workerName: ts.workerName }
}
acc[ts.workerId].grossPay += ts.amount
return acc
}, {} as Record<string, { grossPay: number; workerName: string }>)
return Object.entries(workerTotals).map(([workerId, data]) =>
calculatePayroll(workerId, data.grossPay)
)
}, [timesheets, calculatePayroll])
const calculateHolidayPay = useCallback((
workerId: string,
startDate: Date,
endDate: Date,
holidayAccrualRate: number = 0.1207
): HolidayPayCalculation => {
const worker = workers.find(w => w.id === workerId)
const workerTimesheets = timesheets.filter(ts => {
if (ts.workerId !== workerId) return false
const tsDate = new Date(ts.weekEnding)
return tsDate >= startDate && tsDate <= endDate
})
const eligibleHours = workerTimesheets.reduce((sum, ts) => sum + ts.hours, 0)
const accruedHoliday = eligibleHours * holidayAccrualRate
const avgRate = workerTimesheets.length > 0
? workerTimesheets.reduce((sum, ts) => sum + (ts.rate || 0), 0) / workerTimesheets.length
: 0
const totalHolidayPay = accruedHoliday * avgRate
return {
workerId,
workerName: worker?.name || 'Unknown',
eligibleHours,
holidayAccrualRate,
accruedHoliday,
usedHoliday: 0,
remainingHoliday: accruedHoliday,
holidayPayRate: avgRate,
totalHolidayPay
}
}, [workers, timesheets])
const processPayrollRun = useCallback(async (
periodEnding: string,
timesheetIds: string[]
): Promise<PayrollRun> => {
setIsProcessing(true)
try {
const calculations = calculateBatchPayroll(timesheetIds)
const totalAmount = calculations.reduce((sum, calc) => sum + calc.netPay, 0)
const payrollRun: PayrollRun = {
id: `PR-${Date.now()}`,
periodEnding,
workersCount: calculations.length,
totalAmount,
status: 'completed',
processedDate: new Date().toISOString()
}
setPayrollRuns(current => [...(current || []), payrollRun])
return payrollRun
} finally {
setIsProcessing(false)
}
}, [calculateBatchPayroll, setPayrollRuns])
const calculateCISDeduction = useCallback((grossPay: number, cisRate: number = 0.20): number => {
return grossPay * cisRate
}, [])
const calculateStatutoryPayments = useCallback((
weeklyRate: number,
weeks: number,
type: 'sick' | 'maternity' | 'paternity'
): number => {
const rates = {
sick: 116.75,
maternity: 184.03,
paternity: 184.03
}
const weeklyAmount = Math.min(weeklyRate * 0.9, rates[type])
return weeklyAmount * weeks
}, [])
return {
payrollConfig,
isProcessing,
calculatePayroll,
calculateBatchPayroll,
calculateHolidayPay,
processPayrollRun,
calculateCISDeduction,
calculateStatutoryPayments,
calculateIncomeTax,
calculateNationalInsurance,
calculateEmployerNI,
payrollRuns
}
}

View File

@@ -0,0 +1,334 @@
import { useState, useCallback, useMemo } from 'react'
import { useKV } from '@github/spark/hooks'
import type { Timesheet, ShiftEntry, ShiftType, DayOfWeek, RateCard } from '@/lib/types'
interface ShiftPremium {
shiftType: ShiftType
multiplier: number
description: string
}
interface TimePattern {
maxHoursPerDay: number
maxHoursPerWeek: number
maxConsecutiveDays: number
minBreakMinutes: number
minRestHours: number
}
interface TimesheetValidationResult {
isValid: boolean
errors: string[]
warnings: string[]
}
interface WorkingTimeAnalytics {
totalHours: number
standardHours: number
overtimeHours: number
weekendHours: number
nightHours: number
avgHoursPerDay: number
daysWorked: number
longestShift: number
shortestShift: number
}
interface ShiftCalculationOptions {
rateCard?: RateCard
baseRate?: number
applyPremiums?: boolean
roundToNearest?: number
}
const DEFAULT_SHIFT_PREMIUMS: ShiftPremium[] = [
{ shiftType: 'standard', multiplier: 1.0, description: 'Standard rate' },
{ shiftType: 'overtime', multiplier: 1.5, description: 'Time and a half' },
{ shiftType: 'weekend', multiplier: 1.5, description: 'Weekend premium' },
{ shiftType: 'night', multiplier: 1.33, description: 'Night shift premium' },
{ shiftType: 'holiday', multiplier: 2.0, description: 'Bank holiday rate' },
{ shiftType: 'evening', multiplier: 1.25, description: 'Evening premium' },
{ shiftType: 'early-morning', multiplier: 1.25, description: 'Early morning premium' },
{ shiftType: 'split-shift', multiplier: 1.15, description: 'Split shift premium' }
]
const DEFAULT_TIME_PATTERN: TimePattern = {
maxHoursPerDay: 12,
maxHoursPerWeek: 48,
maxConsecutiveDays: 6,
minBreakMinutes: 30,
minRestHours: 11
}
export function useTimeTracking() {
const [timesheets = [], setTimesheets] = useKV<Timesheet[]>('timesheets', [])
const [rateCards = []] = useKV<RateCard[]>('rate-cards', [])
const [shiftPremiums] = useState<ShiftPremium[]>(DEFAULT_SHIFT_PREMIUMS)
const calculateShiftHours = useCallback((
startTime: string,
endTime: string,
breakMinutes: number = 0
): number => {
const [startHour, startMin] = startTime.split(':').map(Number)
const [endHour, endMin] = endTime.split(':').map(Number)
let totalMinutes = (endHour * 60 + endMin) - (startHour * 60 + startMin)
if (totalMinutes < 0) {
totalMinutes += 24 * 60
}
totalMinutes -= breakMinutes
return Math.max(0, totalMinutes / 60)
}, [])
const determineShiftType = useCallback((
startTime: string,
dayOfWeek: DayOfWeek,
isHoliday: boolean = false
): ShiftType => {
if (isHoliday) return 'holiday'
if (dayOfWeek === 'saturday' || dayOfWeek === 'sunday') {
return 'weekend'
}
const hour = parseInt(startTime.split(':')[0])
if (hour >= 22 || hour < 6) return 'night'
if (hour >= 18) return 'evening'
if (hour < 7) return 'early-morning'
return 'standard'
}, [])
const getShiftMultiplier = useCallback((
shiftType: ShiftType,
rateCard?: RateCard
): number => {
if (rateCard) {
switch (shiftType) {
case 'overtime': return rateCard.overtimeMultiplier
case 'weekend': return rateCard.weekendMultiplier
case 'night': return rateCard.nightMultiplier
case 'holiday': return rateCard.holidayMultiplier
default: return 1.0
}
}
const premium = shiftPremiums.find(p => p.shiftType === shiftType)
return premium?.multiplier || 1.0
}, [shiftPremiums])
const calculateShiftPay = useCallback((
shift: Omit<ShiftEntry, 'id' | 'amount' | 'rateMultiplier'>,
options: ShiftCalculationOptions = {}
): ShiftEntry => {
const { rateCard, baseRate = 15, applyPremiums = true, roundToNearest = 0.25 } = options
const hours = calculateShiftHours(shift.startTime, shift.endTime, shift.breakMinutes)
const multiplier = applyPremiums ? getShiftMultiplier(shift.shiftType, rateCard) : 1.0
const effectiveRate = (rateCard?.standardRate || baseRate) * multiplier
let roundedHours = hours
if (roundToNearest > 0) {
roundedHours = Math.round(hours / roundToNearest) * roundToNearest
}
return {
...shift,
id: `SHIFT-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hours: roundedHours,
rate: effectiveRate,
rateMultiplier: multiplier,
amount: roundedHours * effectiveRate
}
}, [calculateShiftHours, getShiftMultiplier])
const validateTimesheet = useCallback((
timesheet: Timesheet,
pattern: TimePattern = DEFAULT_TIME_PATTERN
): TimesheetValidationResult => {
const errors: string[] = []
const warnings: string[] = []
if (timesheet.hours > pattern.maxHoursPerWeek) {
errors.push(`Total hours (${timesheet.hours}) exceeds maximum weekly hours (${pattern.maxHoursPerWeek})`)
}
if (timesheet.shifts && timesheet.shifts.length > 0) {
const shiftsByDay = timesheet.shifts.reduce((acc, shift) => {
if (!acc[shift.date]) acc[shift.date] = []
acc[shift.date].push(shift)
return acc
}, {} as Record<string, ShiftEntry[]>)
Object.entries(shiftsByDay).forEach(([date, shifts]) => {
const dailyHours = shifts.reduce((sum, s) => sum + s.hours, 0)
if (dailyHours > pattern.maxHoursPerDay) {
errors.push(`Hours on ${date} (${dailyHours}) exceed maximum daily hours (${pattern.maxHoursPerDay})`)
}
if (dailyHours > 6) {
const hasBreak = shifts.some(s => s.breakMinutes >= pattern.minBreakMinutes)
if (!hasBreak) {
warnings.push(`No adequate break recorded for ${date} (${dailyHours} hours worked)`)
}
}
})
const dates = Object.keys(shiftsByDay).sort()
let consecutiveDays = 1
for (let i = 1; i < dates.length; i++) {
const prevDate = new Date(dates[i - 1])
const currDate = new Date(dates[i])
const dayDiff = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
if (dayDiff === 1) {
consecutiveDays++
if (consecutiveDays > pattern.maxConsecutiveDays) {
errors.push(`More than ${pattern.maxConsecutiveDays} consecutive working days detected`)
break
}
} else {
consecutiveDays = 1
}
}
}
if (timesheet.amount <= 0) {
errors.push('Timesheet amount must be greater than zero')
}
if (!timesheet.workerName || !timesheet.clientName) {
errors.push('Worker name and client name are required')
}
return {
isValid: errors.length === 0,
errors,
warnings
}
}, [])
const analyzeWorkingTime = useCallback((
workerId: string,
startDate: Date,
endDate: Date
): WorkingTimeAnalytics => {
const workerTimesheets = timesheets.filter(ts => {
if (ts.workerId !== workerId) return false
const tsDate = new Date(ts.weekEnding)
return tsDate >= startDate && tsDate <= endDate
})
const allShifts = workerTimesheets.flatMap(ts => ts.shifts || [])
const totalHours = workerTimesheets.reduce((sum, ts) => sum + ts.hours, 0)
const standardHours = allShifts
.filter(s => s.shiftType === 'standard')
.reduce((sum, s) => sum + s.hours, 0)
const overtimeHours = allShifts
.filter(s => s.shiftType === 'overtime')
.reduce((sum, s) => sum + s.hours, 0)
const weekendHours = allShifts
.filter(s => s.shiftType === 'weekend')
.reduce((sum, s) => sum + s.hours, 0)
const nightHours = allShifts
.filter(s => s.shiftType === 'night')
.reduce((sum, s) => sum + s.hours, 0)
const uniqueDates = new Set(allShifts.map(s => s.date))
const daysWorked = uniqueDates.size
const shiftHours = allShifts.map(s => s.hours)
const longestShift = shiftHours.length > 0 ? Math.max(...shiftHours) : 0
const shortestShift = shiftHours.length > 0 ? Math.min(...shiftHours) : 0
return {
totalHours,
standardHours,
overtimeHours,
weekendHours,
nightHours,
avgHoursPerDay: daysWorked > 0 ? totalHours / daysWorked : 0,
daysWorked,
longestShift,
shortestShift
}
}, [timesheets])
const createTimesheetFromShifts = useCallback((
workerId: string,
workerName: string,
clientName: string,
shifts: ShiftEntry[],
weekEnding: string
): Timesheet => {
const totalHours = shifts.reduce((sum, shift) => sum + shift.hours, 0)
const totalAmount = shifts.reduce((sum, shift) => sum + shift.amount, 0)
return {
id: `TS-${Date.now()}`,
workerId,
workerName,
clientName,
weekEnding,
hours: totalHours,
status: 'pending',
submittedDate: new Date().toISOString(),
amount: totalAmount,
shifts
}
}, [])
const calculateOvertimeHours = useCallback((
shifts: ShiftEntry[],
standardHoursThreshold: number = 40
): { standard: number; overtime: number } => {
const totalHours = shifts.reduce((sum, shift) => sum + shift.hours, 0)
return {
standard: Math.min(totalHours, standardHoursThreshold),
overtime: Math.max(0, totalHours - standardHoursThreshold)
}
}, [])
const findRateCard = useCallback((
clientName?: string,
role?: string
): RateCard | undefined => {
return rateCards.find(rc => {
if (clientName && rc.clientName !== clientName) return false
if (role && rc.role !== role) return false
const now = new Date()
const effectiveFrom = new Date(rc.effectiveFrom)
if (effectiveFrom > now) return false
if (rc.effectiveTo) {
const effectiveTo = new Date(rc.effectiveTo)
if (effectiveTo < now) return false
}
return true
})
}, [rateCards])
return {
timesheets,
shiftPremiums,
calculateShiftHours,
determineShiftType,
getShiftMultiplier,
calculateShiftPay,
validateTimesheet,
analyzeWorkingTime,
createTimesheetFromShifts,
calculateOvertimeHours,
findRateCard
}
}