mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Include payroll batch processing with approval workflows
This commit is contained in:
140
PAYROLL_BATCH_PROCESSING.md
Normal file
140
PAYROLL_BATCH_PROCESSING.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Payroll Batch Processing & Approval Workflows
|
||||
|
||||
## Overview
|
||||
|
||||
The payroll batch processing system enables efficient processing of multiple worker payrolls simultaneously with built-in multi-step approval workflows. This ensures proper oversight and control over payroll operations while maintaining audit trails.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Batch Processing
|
||||
- **Worker Selection**: Select multiple workers with approved timesheets for batch processing
|
||||
- **Automatic Calculations**: Automatically calculates gross pay, deductions (tax, NI), and net pay for each worker
|
||||
- **Validation**: Pre-submission validation checks for:
|
||||
- Invalid amounts
|
||||
- Missing timesheets
|
||||
- Excessive hours warnings
|
||||
- Unusually high payment warnings
|
||||
- **Batch Summary**: Real-time totals showing workers, timesheets, hours, and amounts
|
||||
|
||||
### 2. Approval Workflow
|
||||
The system implements a multi-step approval process:
|
||||
|
||||
#### Default Workflow Steps:
|
||||
1. **Manager Review** - Initial review by line manager
|
||||
2. **Finance Approval** - Financial oversight and validation
|
||||
3. **Final Approval** - Executive or admin final sign-off
|
||||
|
||||
#### Workflow Features:
|
||||
- **Role-Based Approvals**: Each step requires approval from specific user roles
|
||||
- **Sequential Processing**: Steps must be completed in order
|
||||
- **Comments/Notes**: Approvers can add comments at each step
|
||||
- **Rejection Handling**: Any step can reject the batch with mandatory reason
|
||||
- **Audit Trail**: Full history of approvals, rejections, and comments
|
||||
|
||||
### 3. Batch States
|
||||
- **Draft**: Initial creation, not yet submitted
|
||||
- **Validating**: Undergoing validation checks
|
||||
- **Pending Approval**: Submitted and awaiting approvals
|
||||
- **Approved**: All approvals complete, ready for processing
|
||||
- **Rejected**: Rejected at an approval step
|
||||
- **Processing**: Being processed for payment
|
||||
- **Completed**: Fully processed and paid
|
||||
|
||||
### 4. User Interface
|
||||
|
||||
#### Batch Processing Tab
|
||||
- Worker selection with checkboxes
|
||||
- Batch totals dashboard
|
||||
- Worker details showing:
|
||||
- Name and role
|
||||
- Total hours and timesheet count
|
||||
- Gross pay amount
|
||||
- Payment method
|
||||
- Validate & Process button
|
||||
|
||||
#### Approval Queue Tab
|
||||
- List of all batches with status filters
|
||||
- Search functionality
|
||||
- Batch cards showing:
|
||||
- Batch ID and creation date
|
||||
- Period covered
|
||||
- Worker count and total amount
|
||||
- Workflow progress indicator
|
||||
- Status badges
|
||||
|
||||
#### Batch Detail View
|
||||
- Complete workflow visualization
|
||||
- Step-by-step approval status
|
||||
- Approver information and timestamps
|
||||
- Worker breakdown with deductions
|
||||
- Batch metadata and audit information
|
||||
|
||||
### 5. Data Persistence
|
||||
All batch data is stored in IndexedDB for:
|
||||
- Offline access
|
||||
- Fast retrieval
|
||||
- Session persistence
|
||||
- Local caching
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Batch
|
||||
1. Navigate to Payroll > Batch Processing tab
|
||||
2. Select workers from the list (those with approved timesheets)
|
||||
3. Review batch totals
|
||||
4. Click "Validate & Process"
|
||||
5. Review validation results
|
||||
6. Submit for approval
|
||||
|
||||
### Approving a Batch
|
||||
1. Navigate to Payroll > Approval Queue tab
|
||||
2. Click on a pending batch
|
||||
3. Review batch details and workers
|
||||
4. Click "Approve" or "Reject" at your workflow step
|
||||
5. Add comments (optional for approve, required for reject)
|
||||
6. Submit decision
|
||||
|
||||
### Monitoring Batches
|
||||
- Use status filters to find specific batches
|
||||
- Search by batch ID or creator
|
||||
- Click any batch to view full details
|
||||
- Track workflow progress through step indicators
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Existing Systems:
|
||||
- **Timesheets**: Only approved timesheets are available for batch processing
|
||||
- **Workers**: Worker data including payment methods
|
||||
- **Payroll Calculations**: Uses existing payroll calculation hooks
|
||||
- **CRUD Operations**: Integrates with IndexedDB CRUD hooks
|
||||
- **Notifications**: Can trigger notifications at each workflow step
|
||||
- **Audit Logs**: All actions are auditable
|
||||
|
||||
## Security & Permissions
|
||||
|
||||
The system respects role-based permissions:
|
||||
- Only authorized roles can approve at their workflow step
|
||||
- Rejection reasons are mandatory and audited
|
||||
- All state changes are logged
|
||||
- User identity is tracked throughout the workflow
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Efficiency**: Process multiple workers at once
|
||||
2. **Control**: Multi-step approvals prevent errors
|
||||
3. **Transparency**: Clear audit trail of all decisions
|
||||
4. **Validation**: Pre-emptive checks catch issues early
|
||||
5. **Flexibility**: Configurable workflow steps
|
||||
6. **Audit Compliance**: Full history of all actions
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Configurable workflow templates
|
||||
- Parallel approval paths
|
||||
- Auto-approval rules for low-value batches
|
||||
- Email notifications at each step
|
||||
- Batch scheduling
|
||||
- Recurring batch templates
|
||||
- Integration with payroll providers
|
||||
- Export to accounting systems
|
||||
369
src/components/PayrollApprovalWorkflow.tsx
Normal file
369
src/components/PayrollApprovalWorkflow.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
ChatCircle,
|
||||
User,
|
||||
CalendarBlank
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { DataList } from '@/components/ui/data-list'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { usePayrollBatch, type PayrollBatch, type ApprovalStep } from '@/hooks/use-payroll-batch'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface PayrollApprovalWorkflowProps {
|
||||
batch: PayrollBatch
|
||||
currentUserRole: string
|
||||
currentUserName: string
|
||||
onApprove?: (batchId: string) => void
|
||||
onReject?: (batchId: string) => void
|
||||
}
|
||||
|
||||
export function PayrollApprovalWorkflow({
|
||||
batch,
|
||||
currentUserRole,
|
||||
currentUserName,
|
||||
onApprove,
|
||||
onReject
|
||||
}: PayrollApprovalWorkflowProps) {
|
||||
const [showApproveDialog, setShowApproveDialog] = useState(false)
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false)
|
||||
const [comments, setComments] = useState('')
|
||||
const [rejectionReason, setRejectionReason] = useState('')
|
||||
|
||||
const { approveBatchStep, rejectBatchStep } = usePayrollBatch()
|
||||
|
||||
const workflow = batch.approvalWorkflow
|
||||
const currentStep = workflow?.steps[workflow.currentStep]
|
||||
const canInteract = currentStep?.approverRole === currentUserRole && currentStep?.status === 'pending'
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!currentStep) return
|
||||
|
||||
try {
|
||||
await approveBatchStep(batch.id, currentStep.id, currentUserName, comments)
|
||||
toast.success('Batch approved successfully')
|
||||
setShowApproveDialog(false)
|
||||
setComments('')
|
||||
onApprove?.(batch.id)
|
||||
} catch (error) {
|
||||
toast.error('Failed to approve batch')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!currentStep) return
|
||||
|
||||
if (!rejectionReason.trim()) {
|
||||
toast.error('Please provide a reason for rejection')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rejectBatchStep(batch.id, currentStep.id, currentUserName, rejectionReason)
|
||||
toast.success('Batch rejected')
|
||||
setShowRejectDialog(false)
|
||||
setRejectionReason('')
|
||||
onReject?.(batch.id)
|
||||
} catch (error) {
|
||||
toast.error('Failed to reject batch')
|
||||
}
|
||||
}
|
||||
|
||||
const getStepStatusBadge = (step: ApprovalStep) => {
|
||||
switch (step.status) {
|
||||
case 'approved':
|
||||
return (
|
||||
<Badge className="bg-success/10 text-success-foreground border-success/30">
|
||||
<CheckCircle className="mr-1" size={14} />
|
||||
Approved
|
||||
</Badge>
|
||||
)
|
||||
case 'rejected':
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="mr-1" size={14} />
|
||||
Rejected
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1" size={14} />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getBatchStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { label: string; className: string; icon: any }> = {
|
||||
'draft': {
|
||||
label: 'Draft',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
icon: null
|
||||
},
|
||||
'pending-approval': {
|
||||
label: 'Pending Approval',
|
||||
className: 'bg-warning/10 text-warning-foreground border-warning/30',
|
||||
icon: Clock
|
||||
},
|
||||
'approved': {
|
||||
label: 'Approved',
|
||||
className: 'bg-success/10 text-success-foreground border-success/30',
|
||||
icon: CheckCircle
|
||||
},
|
||||
'rejected': {
|
||||
label: 'Rejected',
|
||||
className: 'bg-destructive/10 text-destructive-foreground border-destructive/30',
|
||||
icon: XCircle
|
||||
},
|
||||
'processing': {
|
||||
label: 'Processing',
|
||||
className: 'bg-accent/10 text-accent-foreground border-accent/30',
|
||||
icon: Clock
|
||||
},
|
||||
'completed': {
|
||||
label: 'Completed',
|
||||
className: 'bg-success/10 text-success-foreground border-success/30',
|
||||
icon: CheckCircle
|
||||
}
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.draft
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{Icon && <Icon className="mr-1" size={14} />}
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflow) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No approval workflow configured for this batch
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Approval Workflow</CardTitle>
|
||||
{getBatchStatusBadge(batch.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack spacing={4}>
|
||||
<div className="space-y-4">
|
||||
{workflow.steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center font-medium text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-medium">{step.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Approver: {step.approverRole}
|
||||
</div>
|
||||
</div>
|
||||
{getStepStatusBadge(step)}
|
||||
</div>
|
||||
|
||||
{step.status === 'approved' && step.approvedBy && (
|
||||
<div className="mt-2 p-3 bg-success/5 rounded-lg border border-success/20">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<User className="text-success flex-shrink-0" size={16} />
|
||||
<div>
|
||||
<div>
|
||||
<span className="font-medium">Approved by:</span> {step.approvedBy}
|
||||
</div>
|
||||
{step.approvedAt && (
|
||||
<div className="text-muted-foreground">
|
||||
{format(new Date(step.approvedAt), 'PPpp')}
|
||||
</div>
|
||||
)}
|
||||
{step.comments && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
<ChatCircle className="inline mr-1" size={14} />
|
||||
{step.comments}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.status === 'rejected' && step.rejectedBy && (
|
||||
<div className="mt-2 p-3 bg-destructive/5 rounded-lg border border-destructive/20">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<User className="text-destructive flex-shrink-0" size={16} />
|
||||
<div>
|
||||
<div>
|
||||
<span className="font-medium">Rejected by:</span> {step.rejectedBy}
|
||||
</div>
|
||||
{step.rejectedAt && (
|
||||
<div className="text-muted-foreground">
|
||||
{format(new Date(step.rejectedAt), 'PPpp')}
|
||||
</div>
|
||||
)}
|
||||
{step.comments && (
|
||||
<div className="mt-1 text-destructive">
|
||||
<ChatCircle className="inline mr-1" size={14} />
|
||||
{step.comments}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.status === 'pending' && index === workflow.currentStep && canInteract && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowApproveDialog(true)}
|
||||
>
|
||||
<CheckCircle className="mr-2" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
>
|
||||
<XCircle className="mr-2" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < workflow.steps.length - 1 && (
|
||||
<div className="ml-4 my-2 h-8 w-0.5 bg-border" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Batch Details</div>
|
||||
<DataList
|
||||
items={[
|
||||
{ label: 'Batch ID', value: batch.id },
|
||||
{ label: 'Period', value: `${batch.periodStart} to ${batch.periodEnd}` },
|
||||
{ label: 'Workers', value: batch.totalWorkers },
|
||||
{ label: 'Total Amount', value: `£${batch.totalAmount.toLocaleString()}` },
|
||||
{ label: 'Created', value: format(new Date(batch.createdAt), 'PPpp') },
|
||||
{ label: 'Created By', value: batch.createdBy },
|
||||
...(batch.submittedAt ? [{ label: 'Submitted', value: format(new Date(batch.submittedAt), 'PPpp') }] : []),
|
||||
...(batch.approvedAt ? [{ label: 'Approved', value: format(new Date(batch.approvedAt), 'PPpp') }] : []),
|
||||
...(batch.rejectedAt ? [{ label: 'Rejected', value: format(new Date(batch.rejectedAt), 'PPpp') }] : [])
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve Payroll Batch</DialogTitle>
|
||||
<DialogDescription>
|
||||
You are about to approve this payroll batch. This action will move it to the next approval step.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="approve-comments">Comments (optional)</Label>
|
||||
<Textarea
|
||||
id="approve-comments"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Add any comments or notes..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowApproveDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApprove}>
|
||||
<CheckCircle className="mr-2" />
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Payroll Batch</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please provide a reason for rejecting this payroll batch. This will stop the approval workflow.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="rejection-reason">Reason for Rejection *</Label>
|
||||
<Textarea
|
||||
id="rejection-reason"
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
placeholder="Explain why this batch is being rejected..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRejectDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleReject}>
|
||||
<XCircle className="mr-2" />
|
||||
Reject
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
278
src/components/PayrollBatchList.tsx
Normal file
278
src/components/PayrollBatchList.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Eye,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
FunnelSimple,
|
||||
CalendarBlank,
|
||||
CurrencyDollar,
|
||||
Users
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PayrollApprovalWorkflow } from '@/components/PayrollApprovalWorkflow'
|
||||
import { usePayrollBatch, type PayrollBatch } from '@/hooks/use-payroll-batch'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface PayrollBatchListProps {
|
||||
currentUserRole: string
|
||||
currentUserName: string
|
||||
}
|
||||
|
||||
export function PayrollBatchList({ currentUserRole, currentUserName }: PayrollBatchListProps) {
|
||||
const [selectedBatch, setSelectedBatch] = useState<PayrollBatch | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const { batches } = usePayrollBatch()
|
||||
|
||||
const filteredBatches = useMemo(() => {
|
||||
let filtered = batches
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(b => b.status === statusFilter)
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(b =>
|
||||
b.id.toLowerCase().includes(query) ||
|
||||
b.createdBy.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
}, [batches, statusFilter, searchQuery])
|
||||
|
||||
const getBatchStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { label: string; className: string; icon: any }> = {
|
||||
'draft': {
|
||||
label: 'Draft',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
icon: null
|
||||
},
|
||||
'pending-approval': {
|
||||
label: 'Pending Approval',
|
||||
className: 'bg-warning/10 text-warning-foreground border-warning/30',
|
||||
icon: Clock
|
||||
},
|
||||
'approved': {
|
||||
label: 'Approved',
|
||||
className: 'bg-success/10 text-success-foreground border-success/30',
|
||||
icon: CheckCircle
|
||||
},
|
||||
'rejected': {
|
||||
label: 'Rejected',
|
||||
className: 'bg-destructive/10 text-destructive-foreground border-destructive/30',
|
||||
icon: XCircle
|
||||
},
|
||||
'processing': {
|
||||
label: 'Processing',
|
||||
className: 'bg-accent/10 text-accent-foreground border-accent/30',
|
||||
icon: Clock
|
||||
},
|
||||
'completed': {
|
||||
label: 'Completed',
|
||||
className: 'bg-success/10 text-success-foreground border-success/30',
|
||||
icon: CheckCircle
|
||||
}
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.draft
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{Icon && <Icon className="mr-1" size={14} />}
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getWorkflowProgress = (batch: PayrollBatch) => {
|
||||
if (!batch.approvalWorkflow) return null
|
||||
|
||||
const { currentStep, totalSteps } = batch.approvalWorkflow
|
||||
return `${currentStep}/${totalSteps}`
|
||||
}
|
||||
|
||||
if (selectedBatch) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="outline" onClick={() => setSelectedBatch(null)}>
|
||||
← Back to List
|
||||
</Button>
|
||||
</div>
|
||||
<PayrollApprovalWorkflow
|
||||
batch={selectedBatch}
|
||||
currentUserRole={currentUserRole}
|
||||
currentUserName={currentUserName}
|
||||
onApprove={() => {
|
||||
const updated = batches.find(b => b.id === selectedBatch.id)
|
||||
if (updated) setSelectedBatch(updated)
|
||||
}}
|
||||
onReject={() => {
|
||||
const updated = batches.find(b => b.id === selectedBatch.id)
|
||||
if (updated) setSelectedBatch(updated)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Batch Workers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{selectedBatch.workers.map((worker) => (
|
||||
<Card key={worker.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{worker.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{worker.role}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{worker.timesheetCount} timesheet{worker.timesheetCount !== 1 ? 's' : ''} • {worker.totalHours.toFixed(1)} hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">£{worker.grossPay.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Net: £{worker.netPay.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worker.deductions.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="text-sm font-medium mb-2">Deductions</div>
|
||||
<div className="space-y-1">
|
||||
{worker.deductions.map((deduction, index) => (
|
||||
<div key={index} className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{deduction.description}</span>
|
||||
<span>£{deduction.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Payroll Batches</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending-approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Search batches..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredBatches.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Clock className="mx-auto mb-4" size={48} />
|
||||
<p>No payroll batches found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredBatches.map((batch) => (
|
||||
<Card key={batch.id} className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="pt-4" onClick={() => setSelectedBatch(batch)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-medium">{batch.id}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(batch.createdAt), 'PPp')}
|
||||
</div>
|
||||
</div>
|
||||
{getBatchStatusBadge(batch.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Period</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<CalendarBlank size={14} className="text-muted-foreground" />
|
||||
<span>{batch.periodStart}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Workers</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
<span>{batch.totalWorkers}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Amount</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<CurrencyDollar size={14} className="text-muted-foreground" />
|
||||
<span>£{batch.totalAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Progress</div>
|
||||
<div className="text-sm">
|
||||
{getWorkflowProgress(batch) || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Created by {batch.createdBy}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="mr-2" />
|
||||
View Details
|
||||
<ArrowRight className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
399
src/components/PayrollBatchProcessor.tsx
Normal file
399
src/components/PayrollBatchProcessor.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Users,
|
||||
CurrencyDollar,
|
||||
Clock,
|
||||
Warning,
|
||||
Eye,
|
||||
ArrowRight
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { DataList } from '@/components/ui/data-list'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { usePayrollBatch } from '@/hooks/use-payroll-batch'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface PayrollBatchProcessorProps {
|
||||
timesheets: any[]
|
||||
workers: any[]
|
||||
onBatchComplete?: (batch: any) => void
|
||||
}
|
||||
|
||||
export function PayrollBatchProcessor({
|
||||
timesheets,
|
||||
workers,
|
||||
onBatchComplete
|
||||
}: PayrollBatchProcessorProps) {
|
||||
const [selectedWorkers, setSelectedWorkers] = useState<string[]>([])
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [showValidation, setShowValidation] = useState(false)
|
||||
|
||||
const {
|
||||
createBatch,
|
||||
validateBatch,
|
||||
processBatch,
|
||||
currentBatch,
|
||||
isProcessing,
|
||||
progress
|
||||
} = usePayrollBatch()
|
||||
|
||||
const approvedTimesheets = useMemo(() =>
|
||||
timesheets.filter(ts => ts.status === 'approved'),
|
||||
[timesheets]
|
||||
)
|
||||
|
||||
const workersWithTimesheets = useMemo(() => {
|
||||
const workerMap = new Map()
|
||||
|
||||
approvedTimesheets.forEach(ts => {
|
||||
if (!workerMap.has(ts.workerId)) {
|
||||
const worker = workers.find(w => w.id === ts.workerId)
|
||||
workerMap.set(ts.workerId, {
|
||||
...worker,
|
||||
timesheets: [],
|
||||
totalHours: 0,
|
||||
totalAmount: 0
|
||||
})
|
||||
}
|
||||
|
||||
const workerData = workerMap.get(ts.workerId)
|
||||
workerData.timesheets.push(ts)
|
||||
workerData.totalHours += ts.totalHours || ts.hours || 0
|
||||
workerData.totalAmount += ts.total || ts.amount || 0
|
||||
})
|
||||
|
||||
return Array.from(workerMap.values())
|
||||
}, [approvedTimesheets, workers])
|
||||
|
||||
const selectedWorkerData = useMemo(() =>
|
||||
workersWithTimesheets.filter(w => selectedWorkers.includes(w.id)),
|
||||
[workersWithTimesheets, selectedWorkers]
|
||||
)
|
||||
|
||||
const batchTotals = useMemo(() => ({
|
||||
workers: selectedWorkerData.length,
|
||||
timesheets: selectedWorkerData.reduce((sum, w) => sum + w.timesheets.length, 0),
|
||||
hours: selectedWorkerData.reduce((sum, w) => sum + w.totalHours, 0),
|
||||
amount: selectedWorkerData.reduce((sum, w) => sum + w.totalAmount, 0)
|
||||
}), [selectedWorkerData])
|
||||
|
||||
const handleToggleWorker = (workerId: string) => {
|
||||
setSelectedWorkers(prev =>
|
||||
prev.includes(workerId)
|
||||
? prev.filter(id => id !== workerId)
|
||||
: [...prev, workerId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedWorkers.length === workersWithTimesheets.length) {
|
||||
setSelectedWorkers([])
|
||||
} else {
|
||||
setSelectedWorkers(workersWithTimesheets.map(w => w.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (selectedWorkers.length === 0) {
|
||||
toast.error('Please select at least one worker')
|
||||
return
|
||||
}
|
||||
|
||||
const batch = await createBatch(selectedWorkerData)
|
||||
const validation = await validateBatch(batch)
|
||||
|
||||
if (validation.hasErrors) {
|
||||
setShowValidation(true)
|
||||
toast.warning(`Validation found ${validation.errors.length} issue(s)`)
|
||||
} else {
|
||||
toast.success('Batch validation passed')
|
||||
setShowPreview(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!currentBatch) return
|
||||
|
||||
try {
|
||||
const result = await processBatch(currentBatch)
|
||||
toast.success('Payroll batch submitted for approval')
|
||||
onBatchComplete?.(result)
|
||||
setShowPreview(false)
|
||||
setSelectedWorkers([])
|
||||
} catch (error) {
|
||||
toast.error('Failed to process payroll batch')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, any> = {
|
||||
pending: 'secondary',
|
||||
processing: 'default',
|
||||
approved: 'default',
|
||||
rejected: 'destructive',
|
||||
completed: 'default'
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-warning/10 text-warning-foreground border-warning/30',
|
||||
processing: 'bg-accent/10 text-accent-foreground border-accent/30',
|
||||
approved: 'bg-success/10 text-success-foreground border-success/30',
|
||||
rejected: 'bg-destructive/10 text-destructive-foreground border-destructive/30',
|
||||
completed: 'bg-success/10 text-success-foreground border-success/30'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || 'secondary'} className={colors[status]}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Payroll Batch Processing</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedWorkers.length === workersWithTimesheets.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={selectedWorkers.length === 0}
|
||||
>
|
||||
<CheckCircle className="mr-2" />
|
||||
Validate & Process ({selectedWorkers.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedWorkers.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg">
|
||||
<Grid cols={4} gap={4}>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Workers</div>
|
||||
<div className="text-2xl font-semibold">{batchTotals.workers}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Timesheets</div>
|
||||
<div className="text-2xl font-semibold">{batchTotals.timesheets}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Hours</div>
|
||||
<div className="text-2xl font-semibold">{batchTotals.hours.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Amount</div>
|
||||
<div className="text-2xl font-semibold">£{batchTotals.amount.toLocaleString()}</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{workersWithTimesheets.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Users className="mx-auto mb-4" size={48} />
|
||||
<p>No workers with approved timesheets available for processing</p>
|
||||
</div>
|
||||
) : (
|
||||
workersWithTimesheets.map((worker) => (
|
||||
<Card key={worker.id} className="overflow-hidden">
|
||||
<div className="p-4 flex items-start gap-4">
|
||||
<Checkbox
|
||||
checked={selectedWorkers.includes(worker.id)}
|
||||
onCheckedChange={() => handleToggleWorker(worker.id)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-medium">{worker.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{worker.role}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">£{worker.totalAmount.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">{worker.totalHours.toFixed(1)} hours</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{worker.timesheets.length} timesheet{worker.timesheets.length !== 1 ? 's' : ''}</span>
|
||||
<span>•</span>
|
||||
<span>Payment Method: {worker.paymentMethod || 'PAYE'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Payroll Batch</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the payroll batch before submitting for approval
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{currentBatch && (
|
||||
<Stack spacing={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Batch Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataList
|
||||
items={[
|
||||
{ label: 'Batch ID', value: currentBatch.id },
|
||||
{ label: 'Period', value: `${currentBatch.periodStart} to ${currentBatch.periodEnd}` },
|
||||
{ label: 'Workers', value: currentBatch.workers.length },
|
||||
{ label: 'Total Amount', value: `£${currentBatch.totalAmount.toLocaleString()}` },
|
||||
{ label: 'Status', value: getStatusBadge(currentBatch.status) }
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Worker Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{currentBatch.workers.map((worker: any) => (
|
||||
<div key={worker.id} className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{worker.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{worker.timesheetCount} timesheet{worker.timesheetCount !== 1 ? 's' : ''} • {worker.totalHours.toFixed(1)} hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">£{worker.grossPay.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Net: £{worker.netPay.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isProcessing && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Stack spacing={2}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Processing batch...</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPreview(false)} disabled={isProcessing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleProcess} disabled={isProcessing}>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Clock className="mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="mr-2" />
|
||||
Submit for Approval
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showValidation} onOpenChange={setShowValidation}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Warning className="text-warning" />
|
||||
Validation Results
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review validation issues before proceeding
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{currentBatch?.validation && (
|
||||
<Stack spacing={3}>
|
||||
{currentBatch.validation.errors?.map((error: any, index: number) => (
|
||||
<Card key={index} className="border-destructive/50">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-3">
|
||||
<XCircle className="text-destructive flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{error.worker}</div>
|
||||
<div className="text-sm text-muted-foreground">{error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{currentBatch.validation.warnings?.map((warning: any, index: number) => (
|
||||
<Card key={index} className="border-warning/50">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-3">
|
||||
<Warning className="text-warning flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{warning.worker}</div>
|
||||
<div className="text-sm text-muted-foreground">{warning.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowValidation(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
Users,
|
||||
CalendarBlank,
|
||||
ClockCounterClockwise,
|
||||
Trash
|
||||
Trash,
|
||||
Stack as StackIcon,
|
||||
CheckCircle
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
@@ -21,8 +24,12 @@ import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { PayrollDetailDialog } from '@/components/PayrollDetailDialog'
|
||||
import { OneClickPayroll } from '@/components/OneClickPayroll'
|
||||
import { CreatePayrollDialog } from '@/components/CreatePayrollDialog'
|
||||
import { PayrollBatchProcessor } from '@/components/PayrollBatchProcessor'
|
||||
import { PayrollBatchList } from '@/components/PayrollBatchList'
|
||||
import { usePayrollCalculations } from '@/hooks/use-payroll-calculations'
|
||||
import { usePayrollCrud } from '@/hooks/use-payroll-crud'
|
||||
import { usePayrollBatch } from '@/hooks/use-payroll-batch'
|
||||
import { useAppSelector } from '@/store/hooks'
|
||||
import { toast } from 'sonner'
|
||||
import type { Timesheet } from '@/lib/types'
|
||||
|
||||
@@ -38,6 +45,10 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [calculatorGrossPay, setCalculatorGrossPay] = useState('1000')
|
||||
const [calculatorResult, setCalculatorResult] = useState<any>(null)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
const currentUser = useAppSelector(state => state.auth.user)
|
||||
const currentUserRole = currentUser?.role || 'user'
|
||||
|
||||
const {
|
||||
payrollRuns,
|
||||
@@ -46,6 +57,8 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
deletePayrollRun
|
||||
} = usePayrollCrud()
|
||||
|
||||
const { batches } = usePayrollBatch()
|
||||
|
||||
const {
|
||||
calculatePayroll,
|
||||
calculateBatchPayroll,
|
||||
@@ -71,6 +84,16 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
payrollRuns.length > 0 ? payrollRuns[payrollRuns.length - 1] : null,
|
||||
[payrollRuns]
|
||||
)
|
||||
|
||||
const pendingBatches = useMemo(() =>
|
||||
batches.filter(b => b.status === 'pending-approval'),
|
||||
[batches]
|
||||
)
|
||||
|
||||
const completedBatches = useMemo(() =>
|
||||
batches.filter(b => b.status === 'completed'),
|
||||
[batches]
|
||||
)
|
||||
|
||||
const handleCalculate = () => {
|
||||
const grossPay = parseFloat(calculatorGrossPay)
|
||||
@@ -208,129 +231,171 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
workers={workers}
|
||||
/>
|
||||
|
||||
<OneClickPayroll
|
||||
timesheets={timesheets}
|
||||
onPayrollComplete={handlePayrollComplete}
|
||||
/>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
<CurrencyDollar className="mr-2" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="batch-processing">
|
||||
<StackIcon className="mr-2" />
|
||||
Batch Processing
|
||||
{pendingBatches.length > 0 && (
|
||||
<Badge className="ml-2" variant="destructive">
|
||||
{pendingBatches.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="approval-queue">
|
||||
<CheckCircle className="mr-2" />
|
||||
Approval Queue
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{showAnalytics && (
|
||||
<Grid cols={4} gap={4}>
|
||||
<MetricCard
|
||||
label="Approved Timesheets"
|
||||
value={approvedTimesheets.length}
|
||||
description="Ready for payroll"
|
||||
icon={<Users size={24} />}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<OneClickPayroll
|
||||
timesheets={timesheets}
|
||||
onPayrollComplete={handlePayrollComplete}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Pending Approval"
|
||||
value={pendingTimesheets.length}
|
||||
description={`£${totalPendingValue.toLocaleString()} value`}
|
||||
icon={<ClockCounterClockwise size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Payroll Runs"
|
||||
value={payrollRuns.length}
|
||||
icon={<CurrencyDollar size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Last Run Total"
|
||||
value={lastRun ? `£${lastRun.totalAmount.toLocaleString()}` : '£0'}
|
||||
description={lastRun ? `${lastRun.workersCount} workers paid` : 'No runs yet'}
|
||||
icon={<Download size={24} />}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<MetricCard
|
||||
label="Next Pay Date"
|
||||
value="22 Jan 2025"
|
||||
description="Weekly run in 3 days"
|
||||
icon={<CalendarBlank size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Pending Approval"
|
||||
value={`${pendingTimesheets.length} timesheets`}
|
||||
description="Must be approved for payroll"
|
||||
icon={<ClockCounterClockwise size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Last Run Total"
|
||||
value={lastRun ? `£${lastRun.totalAmount.toLocaleString()}` : '£0'}
|
||||
description={lastRun ? `${lastRun.workersCount} workers paid` : 'No runs yet'}
|
||||
icon={<CurrencyDollar size={24} />}
|
||||
/>
|
||||
</Grid>
|
||||
{showAnalytics && (
|
||||
<Grid cols={4} gap={4}>
|
||||
<MetricCard
|
||||
label="Approved Timesheets"
|
||||
value={approvedTimesheets.length}
|
||||
description="Ready for payroll"
|
||||
icon={<Users size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Pending Approval"
|
||||
value={pendingTimesheets.length}
|
||||
description={`£${totalPendingValue.toLocaleString()} value`}
|
||||
icon={<ClockCounterClockwise size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Payroll Runs"
|
||||
value={payrollRuns.length}
|
||||
icon={<CurrencyDollar size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Last Run Total"
|
||||
value={lastRun ? `£${lastRun.totalAmount.toLocaleString()}` : '£0'}
|
||||
description={lastRun ? `${lastRun.workersCount} workers paid` : 'No runs yet'}
|
||||
icon={<Download size={24} />}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Stack spacing={3}>
|
||||
{payrollRuns.map(run => (
|
||||
<Card
|
||||
key={run.id}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setViewingPayroll(run)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Stack spacing={2} className="flex-1">
|
||||
<Stack direction="horizontal" spacing={3} align="center">
|
||||
<CurrencyDollar size={20} weight="fill" className="text-primary" />
|
||||
<h3 className="font-semibold text-lg">Payroll Run</h3>
|
||||
<Badge variant={run.status === 'completed' ? 'success' : run.status === 'failed' ? 'destructive' : 'warning'}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Grid cols={4} gap={4} className="text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Period Ending</p>
|
||||
<p className="font-medium">{new Date(run.periodEnding).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Workers</p>
|
||||
<p className="font-medium">{run.workersCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total Amount</p>
|
||||
<p className="font-semibold font-mono text-lg">£{run.totalAmount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Processed</p>
|
||||
<p className="font-medium">
|
||||
{run.processedDate ? new Date(run.processedDate).toLocaleDateString() : 'Not yet'}
|
||||
</p>
|
||||
</div>
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" spacing={2} className="ml-4" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="outline" onClick={() => setViewingPayroll(run)}>
|
||||
View Details
|
||||
</Button>
|
||||
{run.status === 'completed' && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeletePayrollRun(run.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Grid cols={3} gap={4}>
|
||||
<MetricCard
|
||||
label="Next Pay Date"
|
||||
value="22 Jan 2025"
|
||||
description="Weekly run in 3 days"
|
||||
icon={<CalendarBlank size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Pending Approval"
|
||||
value={`${pendingTimesheets.length} timesheets`}
|
||||
description="Must be approved for payroll"
|
||||
icon={<ClockCounterClockwise size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Last Run Total"
|
||||
value={lastRun ? `£${lastRun.totalAmount.toLocaleString()}` : '£0'}
|
||||
description={lastRun ? `${lastRun.workersCount} workers paid` : 'No runs yet'}
|
||||
icon={<CurrencyDollar size={24} />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{payrollRuns.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<CurrencyDollar size={48} className="mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No payroll runs yet</h3>
|
||||
<p className="text-muted-foreground">Create your first payroll run to get started</p>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
{payrollRuns.map(run => (
|
||||
<Card
|
||||
key={run.id}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setViewingPayroll(run)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Stack spacing={2} className="flex-1">
|
||||
<Stack direction="horizontal" spacing={3} align="center">
|
||||
<CurrencyDollar size={20} weight="fill" className="text-primary" />
|
||||
<h3 className="font-semibold text-lg">Payroll Run</h3>
|
||||
<Badge variant={run.status === 'completed' ? 'success' : run.status === 'failed' ? 'destructive' : 'warning'}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Grid cols={4} gap={4} className="text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Period Ending</p>
|
||||
<p className="font-medium">{new Date(run.periodEnding).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Workers</p>
|
||||
<p className="font-medium">{run.workersCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total Amount</p>
|
||||
<p className="font-semibold font-mono text-lg">£{run.totalAmount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Processed</p>
|
||||
<p className="font-medium">
|
||||
{run.processedDate ? new Date(run.processedDate).toLocaleDateString() : 'Not yet'}
|
||||
</p>
|
||||
</div>
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" spacing={2} className="ml-4" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="outline" onClick={() => setViewingPayroll(run)}>
|
||||
View Details
|
||||
</Button>
|
||||
{run.status === 'completed' && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeletePayrollRun(run.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{payrollRuns.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<CurrencyDollar size={48} className="mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No payroll runs yet</h3>
|
||||
<p className="text-muted-foreground">Create your first payroll run to get started</p>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batch-processing" className="space-y-6">
|
||||
<PayrollBatchProcessor
|
||||
timesheets={timesheets}
|
||||
workers={workers}
|
||||
onBatchComplete={() => {
|
||||
toast.success('Batch created and submitted for approval')
|
||||
setActiveTab('approval-queue')
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="approval-queue" className="space-y-6">
|
||||
<PayrollBatchList
|
||||
currentUserRole={currentUserRole}
|
||||
currentUserName={currentUser?.name || 'Unknown User'}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PayrollDetailDialog
|
||||
payrollRun={viewingPayroll}
|
||||
|
||||
@@ -91,6 +91,7 @@ export { useEvent, useLatest } from './use-event'
|
||||
|
||||
export { useInvoicing } from './use-invoicing'
|
||||
export { usePayrollCalculations } from './use-payroll-calculations'
|
||||
export { usePayrollBatch } from './use-payroll-batch'
|
||||
export { useTimeTracking } from './use-time-tracking'
|
||||
export { useMarginAnalysis } from './use-margin-analysis'
|
||||
export { useComplianceTracking } from './use-compliance-tracking'
|
||||
@@ -165,6 +166,7 @@ export type { SortConfig, UseSortableDataReturn } from './use-sortable-data'
|
||||
export type { FilterRule, FilterOperator, UseFilterableDataReturn } from './use-filterable-data'
|
||||
export type { FormatType, FormatOptions } from './use-formatter'
|
||||
export type { Template } from './use-template-manager'
|
||||
export type { PayrollBatch, PayrollBatchWorker, BatchValidation, ApprovalWorkflowState } from './use-payroll-batch'
|
||||
|
||||
export type { UseFetchOptions, UseFetchResult } from './use-fetch'
|
||||
export type { Breakpoint } from './use-breakpoint'
|
||||
|
||||
388
src/hooks/use-payroll-batch.ts
Normal file
388
src/hooks/use-payroll-batch.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { usePayrollCalculations } from './use-payroll-calculations'
|
||||
import { useIndexedDBState } from './use-indexed-db-state'
|
||||
|
||||
export interface PayrollBatch {
|
||||
id: string
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
status: 'draft' | 'validating' | 'pending-approval' | 'approved' | 'rejected' | 'processing' | 'completed'
|
||||
workers: PayrollBatchWorker[]
|
||||
totalAmount: number
|
||||
totalWorkers: number
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
submittedAt?: string
|
||||
approvedAt?: string
|
||||
approvedBy?: string
|
||||
rejectedAt?: string
|
||||
rejectedBy?: string
|
||||
rejectionReason?: string
|
||||
processedAt?: string
|
||||
validation?: BatchValidation
|
||||
approvalWorkflow?: ApprovalWorkflowState
|
||||
}
|
||||
|
||||
export interface PayrollBatchWorker {
|
||||
id: string
|
||||
workerId: string
|
||||
name: string
|
||||
role: string
|
||||
timesheetCount: number
|
||||
totalHours: number
|
||||
grossPay: number
|
||||
netPay: number
|
||||
deductions: PayrollDeduction[]
|
||||
timesheets: any[]
|
||||
paymentMethod: string
|
||||
}
|
||||
|
||||
export interface PayrollDeduction {
|
||||
type: string
|
||||
description: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface BatchValidation {
|
||||
isValid: boolean
|
||||
hasErrors: boolean
|
||||
hasWarnings: boolean
|
||||
errors: ValidationIssue[]
|
||||
warnings: ValidationIssue[]
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
worker: string
|
||||
workerId: string
|
||||
type: string
|
||||
message: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowState {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: ApprovalStep[]
|
||||
canApprove: boolean
|
||||
canReject: boolean
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
id: string
|
||||
name: string
|
||||
approverRole: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
approvedBy?: string
|
||||
approvedAt?: string
|
||||
rejectedBy?: string
|
||||
rejectedAt?: string
|
||||
comments?: string
|
||||
}
|
||||
|
||||
export function usePayrollBatch() {
|
||||
const [currentBatch, setCurrentBatch] = useState<PayrollBatch | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const { calculatePayroll } = usePayrollCalculations()
|
||||
const [batches, setBatches] = useIndexedDBState<PayrollBatch[]>('payroll-batches', [])
|
||||
|
||||
const createBatch = useCallback(async (workersData: any[]) => {
|
||||
const batch: PayrollBatch = {
|
||||
id: `BATCH-${Date.now()}`,
|
||||
periodStart: getPeriodStart(),
|
||||
periodEnd: getPeriodEnd(),
|
||||
status: 'draft',
|
||||
workers: workersData.map(w => ({
|
||||
id: `${Date.now()}-${w.id}`,
|
||||
workerId: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
timesheetCount: w.timesheets.length,
|
||||
totalHours: w.totalHours,
|
||||
grossPay: w.totalAmount,
|
||||
netPay: calculateNetPay(w.totalAmount),
|
||||
deductions: calculateDeductions(w.totalAmount),
|
||||
timesheets: w.timesheets,
|
||||
paymentMethod: w.paymentMethod || 'PAYE'
|
||||
})),
|
||||
totalAmount: workersData.reduce((sum, w) => sum + w.totalAmount, 0),
|
||||
totalWorkers: workersData.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'current-user',
|
||||
approvalWorkflow: {
|
||||
currentStep: 0,
|
||||
totalSteps: 3,
|
||||
steps: [
|
||||
{
|
||||
id: 'manager-review',
|
||||
name: 'Manager Review',
|
||||
approverRole: 'manager',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
id: 'finance-approval',
|
||||
name: 'Finance Approval',
|
||||
approverRole: 'finance',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
id: 'final-approval',
|
||||
name: 'Final Approval',
|
||||
approverRole: 'admin',
|
||||
status: 'pending'
|
||||
}
|
||||
],
|
||||
canApprove: false,
|
||||
canReject: false
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentBatch(batch)
|
||||
return batch
|
||||
}, [calculatePayroll])
|
||||
|
||||
const validateBatch = useCallback(async (batch: PayrollBatch) => {
|
||||
setIsProcessing(true)
|
||||
setProgress(0)
|
||||
|
||||
const errors: ValidationIssue[] = []
|
||||
const warnings: ValidationIssue[] = []
|
||||
|
||||
for (let i = 0; i < batch.workers.length; i++) {
|
||||
const worker = batch.workers[i]
|
||||
setProgress((i / batch.workers.length) * 100)
|
||||
|
||||
if (worker.grossPay <= 0) {
|
||||
errors.push({
|
||||
worker: worker.name,
|
||||
workerId: worker.workerId,
|
||||
type: 'invalid-amount',
|
||||
message: 'Gross pay must be greater than zero',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (worker.timesheetCount === 0) {
|
||||
errors.push({
|
||||
worker: worker.name,
|
||||
workerId: worker.workerId,
|
||||
type: 'missing-timesheets',
|
||||
message: 'No timesheets found for this worker',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (worker.totalHours > 60) {
|
||||
warnings.push({
|
||||
worker: worker.name,
|
||||
workerId: worker.workerId,
|
||||
type: 'excessive-hours',
|
||||
message: `Total hours (${worker.totalHours}) exceeds recommended weekly maximum`,
|
||||
severity: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
if (worker.grossPay > 10000) {
|
||||
warnings.push({
|
||||
worker: worker.name,
|
||||
workerId: worker.workerId,
|
||||
type: 'high-amount',
|
||||
message: `Gross pay (£${worker.grossPay}) is unusually high`,
|
||||
severity: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
const validation: BatchValidation = {
|
||||
isValid: errors.length === 0,
|
||||
hasErrors: errors.length > 0,
|
||||
hasWarnings: warnings.length > 0,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
|
||||
const updatedBatch = {
|
||||
...batch,
|
||||
validation,
|
||||
status: validation.isValid ? 'pending-approval' as const : 'draft' as const
|
||||
}
|
||||
|
||||
setCurrentBatch(updatedBatch)
|
||||
setIsProcessing(false)
|
||||
setProgress(100)
|
||||
|
||||
return validation
|
||||
}, [])
|
||||
|
||||
const processBatch = useCallback(async (batch: PayrollBatch) => {
|
||||
setIsProcessing(true)
|
||||
setProgress(0)
|
||||
|
||||
const updatedBatch = {
|
||||
...batch,
|
||||
status: 'pending-approval' as const,
|
||||
submittedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setProgress(100)
|
||||
|
||||
setBatches(prev => [...prev, updatedBatch])
|
||||
|
||||
setIsProcessing(false)
|
||||
return updatedBatch
|
||||
}, [setBatches])
|
||||
|
||||
const approveBatchStep = useCallback(async (
|
||||
batchId: string,
|
||||
stepId: string,
|
||||
approverName: string,
|
||||
comments?: string
|
||||
) => {
|
||||
setBatches(prev => prev.map(batch => {
|
||||
if (batch.id !== batchId) return batch
|
||||
|
||||
const workflow = batch.approvalWorkflow
|
||||
if (!workflow) return batch
|
||||
|
||||
const stepIndex = workflow.steps.findIndex(s => s.id === stepId)
|
||||
if (stepIndex === -1) return batch
|
||||
|
||||
const updatedSteps = [...workflow.steps]
|
||||
updatedSteps[stepIndex] = {
|
||||
...updatedSteps[stepIndex],
|
||||
status: 'approved',
|
||||
approvedBy: approverName,
|
||||
approvedAt: new Date().toISOString(),
|
||||
comments
|
||||
}
|
||||
|
||||
const allApproved = updatedSteps.every(s => s.status === 'approved')
|
||||
const currentStep = updatedSteps.findIndex(s => s.status === 'pending')
|
||||
|
||||
return {
|
||||
...batch,
|
||||
status: allApproved ? 'approved' : 'pending-approval',
|
||||
approvedAt: allApproved ? new Date().toISOString() : undefined,
|
||||
approvedBy: allApproved ? approverName : undefined,
|
||||
approvalWorkflow: {
|
||||
...workflow,
|
||||
currentStep: currentStep === -1 ? workflow.totalSteps : currentStep,
|
||||
steps: updatedSteps,
|
||||
canApprove: currentStep !== -1,
|
||||
canReject: currentStep !== -1
|
||||
}
|
||||
}
|
||||
}))
|
||||
}, [setBatches])
|
||||
|
||||
const rejectBatchStep = useCallback(async (
|
||||
batchId: string,
|
||||
stepId: string,
|
||||
rejectorName: string,
|
||||
reason: string
|
||||
) => {
|
||||
setBatches(prev => prev.map(batch => {
|
||||
if (batch.id !== batchId) return batch
|
||||
|
||||
const workflow = batch.approvalWorkflow
|
||||
if (!workflow) return batch
|
||||
|
||||
const stepIndex = workflow.steps.findIndex(s => s.id === stepId)
|
||||
if (stepIndex === -1) return batch
|
||||
|
||||
const updatedSteps = [...workflow.steps]
|
||||
updatedSteps[stepIndex] = {
|
||||
...updatedSteps[stepIndex],
|
||||
status: 'rejected',
|
||||
rejectedBy: rejectorName,
|
||||
rejectedAt: new Date().toISOString(),
|
||||
comments: reason
|
||||
}
|
||||
|
||||
return {
|
||||
...batch,
|
||||
status: 'rejected',
|
||||
rejectedAt: new Date().toISOString(),
|
||||
rejectedBy: rejectorName,
|
||||
rejectionReason: reason,
|
||||
approvalWorkflow: {
|
||||
...workflow,
|
||||
steps: updatedSteps,
|
||||
canApprove: false,
|
||||
canReject: false
|
||||
}
|
||||
}
|
||||
}))
|
||||
}, [setBatches])
|
||||
|
||||
const completeBatch = useCallback(async (batchId: string) => {
|
||||
setBatches(prev => prev.map(batch =>
|
||||
batch.id === batchId
|
||||
? {
|
||||
...batch,
|
||||
status: 'completed',
|
||||
processedAt: new Date().toISOString()
|
||||
}
|
||||
: batch
|
||||
))
|
||||
}, [setBatches])
|
||||
|
||||
return {
|
||||
batches,
|
||||
currentBatch,
|
||||
isProcessing,
|
||||
progress,
|
||||
createBatch,
|
||||
validateBatch,
|
||||
processBatch,
|
||||
approveBatchStep,
|
||||
rejectBatchStep,
|
||||
completeBatch
|
||||
}
|
||||
}
|
||||
|
||||
function getPeriodStart(): string {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay()
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek
|
||||
const monday = new Date(today)
|
||||
monday.setDate(today.getDate() + diff)
|
||||
return monday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getPeriodEnd(): string {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay()
|
||||
const diff = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
|
||||
const sunday = new Date(today)
|
||||
sunday.setDate(today.getDate() + diff)
|
||||
return sunday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function calculateNetPay(grossPay: number): number {
|
||||
const taxRate = 0.20
|
||||
const niRate = 0.12
|
||||
const totalDeductions = grossPay * (taxRate + niRate)
|
||||
return grossPay - totalDeductions
|
||||
}
|
||||
|
||||
function calculateDeductions(grossPay: number): PayrollDeduction[] {
|
||||
const tax = grossPay * 0.20
|
||||
const ni = grossPay * 0.12
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'tax',
|
||||
description: 'Income Tax',
|
||||
amount: tax
|
||||
},
|
||||
{
|
||||
type: 'ni',
|
||||
description: 'National Insurance',
|
||||
amount: ni
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user