mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Add more specialized business logic hooks (invoicing, payroll calculations, time tracking)
This commit is contained in:
@@ -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>
|
||||
|
||||
449
src/components/BusinessLogicDemo.tsx
Normal file
449
src/components/BusinessLogicDemo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
436
src/hooks/BUSINESS_LOGIC_HOOKS.md
Normal file
436
src/hooks/BUSINESS_LOGIC_HOOKS.md
Normal 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
|
||||
@@ -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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
263
src/hooks/use-compliance-tracking.ts
Normal file
263
src/hooks/use-compliance-tracking.ts
Normal 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
235
src/hooks/use-invoicing.ts
Normal 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
|
||||
}
|
||||
}
|
||||
288
src/hooks/use-margin-analysis.ts
Normal file
288
src/hooks/use-margin-analysis.ts
Normal 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
|
||||
}
|
||||
}
|
||||
307
src/hooks/use-payroll-calculations.ts
Normal file
307
src/hooks/use-payroll-calculations.ts
Normal 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
|
||||
}
|
||||
}
|
||||
334
src/hooks/use-time-tracking.ts
Normal file
334
src/hooks/use-time-tracking.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user