mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-05-05 18:49:36 +00:00
Generated by Spark: Time and rate adjustment wizard
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Stepper } from '@/components/ui/stepper'
|
||||
import { ArrowLeft, ArrowRight, Clock, CurrencyDollar, FileText, CheckCircle, WarningCircle, Info } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface TimeAndRateAdjustment {
|
||||
timesheetId: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
clientName: string
|
||||
originalHours: number
|
||||
originalRate: number
|
||||
adjustedHours?: number
|
||||
adjustedRate?: number
|
||||
adjustmentReason: string
|
||||
adjustmentType: 'time' | 'rate' | 'both'
|
||||
approvalRequired: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface TimeAndRateAdjustmentWizardProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
timesheet: {
|
||||
id: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
clientName: string
|
||||
hoursWorked: number
|
||||
rate: number
|
||||
status: string
|
||||
} | null
|
||||
onSubmit: (adjustment: TimeAndRateAdjustment) => Promise<void>
|
||||
}
|
||||
|
||||
const ADJUSTMENT_REASONS = [
|
||||
{ value: 'client_request', label: 'Client Request' },
|
||||
{ value: 'worker_dispute', label: 'Worker Dispute' },
|
||||
{ value: 'data_entry_error', label: 'Data Entry Error' },
|
||||
{ value: 'contract_change', label: 'Contract Rate Change' },
|
||||
{ value: 'overtime_adjustment', label: 'Overtime Adjustment' },
|
||||
{ value: 'shift_premium', label: 'Shift Premium Applied' },
|
||||
{ value: 'time_correction', label: 'Time Recording Correction' },
|
||||
{ value: 'compliance_adjustment', label: 'Compliance Adjustment' },
|
||||
{ value: 'other', label: 'Other (Specify in Notes)' },
|
||||
]
|
||||
|
||||
export function TimeAndRateAdjustmentWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
timesheet,
|
||||
onSubmit
|
||||
}: TimeAndRateAdjustmentWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [adjustmentType, setAdjustmentType] = useState<'time' | 'rate' | 'both'>('time')
|
||||
const [adjustedHours, setAdjustedHours] = useState<string>('')
|
||||
const [adjustedRate, setAdjustedRate] = useState<string>('')
|
||||
const [adjustmentReason, setAdjustmentReason] = useState<string>('')
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const stepDefinitions = [
|
||||
{ title: 'Select Type', description: 'Choose adjustment type' },
|
||||
{ title: 'Enter Values', description: 'New time/rate values' },
|
||||
{ title: 'Justification', description: 'Reason and notes' },
|
||||
{ title: 'Review', description: 'Confirm changes' },
|
||||
]
|
||||
|
||||
const steps = stepDefinitions.map((step, index) => ({
|
||||
id: `step-${index}`,
|
||||
label: step.title,
|
||||
description: step.description,
|
||||
status: index < currentStep ? 'completed' as const : index === currentStep ? 'current' as const : 'pending' as const,
|
||||
}))
|
||||
|
||||
if (!timesheet) return null
|
||||
|
||||
const originalTotal = timesheet.hoursWorked * timesheet.rate
|
||||
const newHours = adjustmentType === 'time' || adjustmentType === 'both'
|
||||
? parseFloat(adjustedHours) || timesheet.hoursWorked
|
||||
: timesheet.hoursWorked
|
||||
const newRate = adjustmentType === 'rate' || adjustmentType === 'both'
|
||||
? parseFloat(adjustedRate) || timesheet.rate
|
||||
: timesheet.rate
|
||||
const newTotal = newHours * newRate
|
||||
const difference = newTotal - originalTotal
|
||||
const percentageChange = ((difference / originalTotal) * 100).toFixed(2)
|
||||
|
||||
const requiresApproval = Math.abs(difference) > 100 || Math.abs(parseFloat(percentageChange)) > 10
|
||||
|
||||
const handleReset = () => {
|
||||
setCurrentStep(0)
|
||||
setAdjustmentType('time')
|
||||
setAdjustedHours('')
|
||||
setAdjustedRate('')
|
||||
setAdjustmentReason('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
handleReset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep === 0 && !adjustmentType) {
|
||||
toast.error('Please select an adjustment type')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep === 1) {
|
||||
if ((adjustmentType === 'time' || adjustmentType === 'both') && !adjustedHours) {
|
||||
toast.error('Please enter adjusted hours')
|
||||
return
|
||||
}
|
||||
if ((adjustmentType === 'rate' || adjustmentType === 'both') && !adjustedRate) {
|
||||
toast.error('Please enter adjusted rate')
|
||||
return
|
||||
}
|
||||
const hours = parseFloat(adjustedHours)
|
||||
const rate = parseFloat(adjustedRate)
|
||||
if ((adjustmentType === 'time' || adjustmentType === 'both') && (isNaN(hours) || hours < 0)) {
|
||||
toast.error('Hours must be a valid positive number')
|
||||
return
|
||||
}
|
||||
if ((adjustmentType === 'rate' || adjustmentType === 'both') && (isNaN(rate) || rate < 0)) {
|
||||
toast.error('Rate must be a valid positive number')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep === 2 && !adjustmentReason) {
|
||||
toast.error('Please select a reason for adjustment')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStep(prev => Math.min(prev + 1, steps.length - 1))
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 0))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!adjustmentReason) {
|
||||
toast.error('Please provide a reason for the adjustment')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const adjustment: TimeAndRateAdjustment = {
|
||||
timesheetId: timesheet.id,
|
||||
workerId: timesheet.workerId,
|
||||
workerName: timesheet.workerName,
|
||||
clientName: timesheet.clientName,
|
||||
originalHours: timesheet.hoursWorked,
|
||||
originalRate: timesheet.rate,
|
||||
adjustedHours: adjustmentType === 'time' || adjustmentType === 'both'
|
||||
? parseFloat(adjustedHours)
|
||||
: undefined,
|
||||
adjustedRate: adjustmentType === 'rate' || adjustmentType === 'both'
|
||||
? parseFloat(adjustedRate)
|
||||
: undefined,
|
||||
adjustmentReason,
|
||||
adjustmentType,
|
||||
approvalRequired: requiresApproval,
|
||||
notes: notes || undefined,
|
||||
}
|
||||
|
||||
await onSubmit(adjustment)
|
||||
toast.success(requiresApproval
|
||||
? 'Adjustment submitted for approval'
|
||||
: 'Adjustment applied successfully'
|
||||
)
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
toast.error('Failed to submit adjustment')
|
||||
console.error('Adjustment submission error:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Clock className="text-primary" />
|
||||
Time & Rate Adjustment Wizard
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust timesheet hours and rates with full audit trail
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<Stepper steps={steps} />
|
||||
|
||||
<Card className="p-4 bg-muted/50">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Worker</div>
|
||||
<div className="font-medium">{timesheet.workerName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Client</div>
|
||||
<div className="font-medium">{timesheet.clientName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Original Hours</div>
|
||||
<div className="font-medium">{timesheet.hoursWorked}h</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Original Rate</div>
|
||||
<div className="font-medium">£{timesheet.rate.toFixed(2)}/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{currentStep === 0 && (
|
||||
<div className="space-y-4">
|
||||
<Label>What would you like to adjust?</Label>
|
||||
<div className="grid gap-3">
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all hover:border-primary ${
|
||||
adjustmentType === 'time' ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setAdjustmentType('time')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="mt-0.5 text-primary" size={20} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Time Only</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Adjust the number of hours worked
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all hover:border-primary ${
|
||||
adjustmentType === 'rate' ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setAdjustmentType('rate')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<CurrencyDollar className="mt-0.5 text-primary" size={20} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Rate Only</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Adjust the hourly rate
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all hover:border-primary ${
|
||||
adjustmentType === 'both' ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setAdjustmentType('both')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="mt-0.5 text-primary" size={20} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Time & Rate</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Adjust both hours and rate
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
{(adjustmentType === 'time' || adjustmentType === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adjusted-hours">Adjusted Hours</Label>
|
||||
<Input
|
||||
id="adjusted-hours"
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
placeholder={timesheet.hoursWorked.toString()}
|
||||
value={adjustedHours}
|
||||
onChange={(e) => setAdjustedHours(e.target.value)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Original: {timesheet.hoursWorked}h
|
||||
{adjustedHours && (
|
||||
<span className={parseFloat(adjustedHours) > timesheet.hoursWorked ? 'text-success' : 'text-warning'}>
|
||||
{' '}→ {adjustedHours}h ({parseFloat(adjustedHours) > timesheet.hoursWorked ? '+' : ''}{(parseFloat(adjustedHours) - timesheet.hoursWorked).toFixed(2)}h)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adjustmentType === 'rate' || adjustmentType === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adjusted-rate">Adjusted Rate (£/hour)</Label>
|
||||
<Input
|
||||
id="adjusted-rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={timesheet.rate.toString()}
|
||||
value={adjustedRate}
|
||||
onChange={(e) => setAdjustedRate(e.target.value)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Original: £{timesheet.rate.toFixed(2)}/h
|
||||
{adjustedRate && (
|
||||
<span className={parseFloat(adjustedRate) > timesheet.rate ? 'text-success' : 'text-warning'}>
|
||||
{' '}→ £{parseFloat(adjustedRate).toFixed(2)}/h ({parseFloat(adjustedRate) > timesheet.rate ? '+' : ''}£{(parseFloat(adjustedRate) - timesheet.rate).toFixed(2)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adjustedHours || adjustedRate) && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Impact Summary</div>
|
||||
<div className="text-sm">
|
||||
Original Total: £{originalTotal.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
New Total: £{newTotal.toFixed(2)}
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${difference >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
Difference: {difference >= 0 ? '+' : ''}£{difference.toFixed(2)} ({difference >= 0 ? '+' : ''}{percentageChange}%)
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adjustment-reason">Reason for Adjustment *</Label>
|
||||
<Select value={adjustmentReason} onValueChange={setAdjustmentReason}>
|
||||
<SelectTrigger id="adjustment-reason">
|
||||
<SelectValue placeholder="Select a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADJUSTMENT_REASONS.map((reason) => (
|
||||
<SelectItem key={reason.value} value={reason.value}>
|
||||
{reason.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adjustment-notes">Additional Notes</Label>
|
||||
<Textarea
|
||||
id="adjustment-notes"
|
||||
placeholder="Provide additional context for this adjustment..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{adjustmentReason === 'other' && 'Required when selecting "Other"'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<Alert className={requiresApproval ? 'border-warning' : 'border-success'}>
|
||||
{requiresApproval ? <WarningCircle className="text-warning" /> : <CheckCircle className="text-success" />}
|
||||
<AlertDescription>
|
||||
<div className="font-medium">
|
||||
{requiresApproval
|
||||
? 'Approval Required'
|
||||
: 'No Approval Required'}
|
||||
</div>
|
||||
<div className="text-sm mt-1">
|
||||
{requiresApproval
|
||||
? 'This adjustment exceeds threshold limits and will require manager approval before being applied.'
|
||||
: 'This adjustment is within acceptable limits and will be applied immediately.'}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card className="p-4 space-y-3">
|
||||
<div className="font-medium">Adjustment Summary</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Adjustment Type</div>
|
||||
<div className="font-medium capitalize">
|
||||
{adjustmentType === 'both' ? 'Time & Rate' : adjustmentType}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Reason</div>
|
||||
<div className="font-medium">
|
||||
{ADJUSTMENT_REASONS.find(r => r.value === adjustmentReason)?.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{(adjustmentType === 'time' || adjustmentType === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<span>Hours:</span>
|
||||
<span className="font-medium">
|
||||
{timesheet.hoursWorked}h → {newHours}h
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{newHours > timesheet.hoursWorked ? '+' : ''}{(newHours - timesheet.hoursWorked).toFixed(2)}h
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(adjustmentType === 'rate' || adjustmentType === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<span>Rate:</span>
|
||||
<span className="font-medium">
|
||||
£{timesheet.rate.toFixed(2)}/h → £{newRate.toFixed(2)}/h
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{newRate > timesheet.rate ? '+' : ''}£{(newRate - timesheet.rate).toFixed(2)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>Total Impact:</span>
|
||||
<span className={difference >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
£{originalTotal.toFixed(2)} → £{newTotal.toFixed(2)}
|
||||
<Badge variant={difference >= 0 ? 'default' : 'destructive'} className="ml-2">
|
||||
{difference >= 0 ? '+' : ''}£{difference.toFixed(2)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{notes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">Notes:</div>
|
||||
<div className="text-sm">{notes}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft className="mr-2" size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < steps.length - 1 ? (
|
||||
<Button onClick={handleNext}>
|
||||
Next
|
||||
<ArrowRight className="ml-2" size={16} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : requiresApproval ? 'Submit for Approval' : 'Apply Adjustment'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,8 @@ import { Stack } from '@/components/ui/stack'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { TimesheetAdjustmentWizard } from '@/components/TimesheetAdjustmentWizard'
|
||||
import { TimesheetDetailDialog } from '@/components/TimesheetDetailDialog'
|
||||
import { TimeAndRateAdjustmentWizard } from '@/components/TimeAndRateAdjustmentWizard'
|
||||
import { AdvancedSearch, type FilterField } from '@/components/AdvancedSearch'
|
||||
import { TimesheetCreateDialogs } from '@/components/timesheets/TimesheetCreateDialogs'
|
||||
import { TimesheetTabs } from '@/components/timesheets/TimesheetTabs'
|
||||
@@ -215,6 +215,77 @@ export function TimesheetsView({
|
||||
}
|
||||
}, [updateTimesheet, hasPermission])
|
||||
|
||||
const handleTimeAndRateAdjustment = useCallback(async (adjustment: {
|
||||
timesheetId: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
clientName: string
|
||||
originalHours: number
|
||||
originalRate: number
|
||||
adjustedHours?: number
|
||||
adjustedRate?: number
|
||||
adjustmentReason: string
|
||||
adjustmentType: 'time' | 'rate' | 'both'
|
||||
approvalRequired: boolean
|
||||
notes?: string
|
||||
}) => {
|
||||
if (!hasPermission('timesheets.edit')) {
|
||||
toast.error('You do not have permission to adjust timesheets')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const currentTimesheet = timesheets.find(t => t.id === adjustment.timesheetId)
|
||||
const adjustmentRecord: any = {
|
||||
id: `adj-${Date.now()}`,
|
||||
adjustmentDate: new Date().toISOString(),
|
||||
adjustedBy: 'Current User',
|
||||
previousHours: adjustment.originalHours,
|
||||
newHours: adjustment.adjustedHours ?? adjustment.originalHours,
|
||||
previousRate: adjustment.originalRate,
|
||||
newRate: adjustment.adjustedRate ?? adjustment.originalRate,
|
||||
reason: adjustment.adjustmentReason,
|
||||
notes: adjustment.notes,
|
||||
requiresApproval: adjustment.approvalRequired,
|
||||
status: adjustment.approvalRequired ? 'pending_approval' : 'applied'
|
||||
}
|
||||
|
||||
const updates: any = {
|
||||
adjustments: [
|
||||
...(currentTimesheet?.adjustments || []),
|
||||
adjustmentRecord
|
||||
]
|
||||
}
|
||||
|
||||
if (adjustment.adjustedHours !== undefined) {
|
||||
updates.hours = adjustment.adjustedHours
|
||||
}
|
||||
if (adjustment.adjustedRate !== undefined) {
|
||||
updates.rate = adjustment.adjustedRate
|
||||
}
|
||||
if (adjustment.adjustedHours !== undefined || adjustment.adjustedRate !== undefined) {
|
||||
const newHours = adjustment.adjustedHours ?? adjustment.originalHours
|
||||
const newRate = adjustment.adjustedRate ?? adjustment.originalRate
|
||||
updates.amount = newHours * newRate
|
||||
}
|
||||
|
||||
if (adjustment.approvalRequired) {
|
||||
updates.status = 'pending'
|
||||
}
|
||||
|
||||
await updateTimesheet(adjustment.timesheetId, updates)
|
||||
|
||||
if (adjustment.approvalRequired) {
|
||||
toast.success('Adjustment submitted for approval')
|
||||
} else {
|
||||
toast.success('Adjustment applied successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to apply adjustment')
|
||||
console.error('Error applying adjustment:', error)
|
||||
}
|
||||
}, [updateTimesheet, hasPermission, timesheets])
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
if (!hasPermission('timesheets.delete')) {
|
||||
toast.error('You do not have permission to delete timesheets')
|
||||
@@ -491,13 +562,21 @@ export function TimesheetsView({
|
||||
/>
|
||||
|
||||
{selectedTimesheet && (
|
||||
<TimesheetAdjustmentWizard
|
||||
timesheet={selectedTimesheet}
|
||||
<TimeAndRateAdjustmentWizard
|
||||
timesheet={{
|
||||
id: selectedTimesheet.id,
|
||||
workerId: selectedTimesheet.workerId,
|
||||
workerName: selectedTimesheet.workerName,
|
||||
clientName: selectedTimesheet.clientName,
|
||||
hoursWorked: selectedTimesheet.hours,
|
||||
rate: selectedTimesheet.rate || 0,
|
||||
status: selectedTimesheet.status
|
||||
}}
|
||||
open={selectedTimesheet !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTimesheet(null)
|
||||
}}
|
||||
onAdjust={(id, adjustment) => handleAdjust(id, adjustment)}
|
||||
onSubmit={handleTimeAndRateAdjustment}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -96,6 +96,7 @@ export { usePayrollBatch } from './use-payroll-batch'
|
||||
export { useTimeTracking } from './use-time-tracking'
|
||||
export { useMarginAnalysis } from './use-margin-analysis'
|
||||
export { useComplianceTracking } from './use-compliance-tracking'
|
||||
export { useTimeAndRateAdjustment } from './use-time-and-rate-adjustment'
|
||||
|
||||
export { useFocusReturn } from './use-focus-return'
|
||||
export { useAnnounce } from './use-announce'
|
||||
@@ -168,6 +169,7 @@ export type { FilterRule, FilterOperator, UseFilterableDataReturn } from './use-
|
||||
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 { TimeAndRateAdjustmentInput, AdjustmentRecord } from './use-time-and-rate-adjustment'
|
||||
|
||||
export type { UseFetchOptions, UseFetchResult } from './use-fetch'
|
||||
export type { Breakpoint } from './use-breakpoint'
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTimesheetsCrud } from './use-timesheets-crud'
|
||||
import { useAuth } from './use-auth'
|
||||
|
||||
export interface TimeAndRateAdjustmentInput {
|
||||
timesheetId: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
clientName: string
|
||||
originalHours: number
|
||||
originalRate: number
|
||||
adjustedHours?: number
|
||||
adjustedRate?: number
|
||||
adjustmentReason: string
|
||||
adjustmentType: 'time' | 'rate' | 'both'
|
||||
approvalRequired: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface AdjustmentRecord {
|
||||
id: string
|
||||
adjustmentDate: string
|
||||
adjustedBy: string
|
||||
adjustedByUserId: string
|
||||
previousHours: number
|
||||
newHours: number
|
||||
previousRate: number
|
||||
newRate: number
|
||||
previousAmount: number
|
||||
newAmount: number
|
||||
difference: number
|
||||
percentageChange: number
|
||||
reason: string
|
||||
notes?: string
|
||||
requiresApproval: boolean
|
||||
approvalStatus: 'pending_approval' | 'approved' | 'rejected' | 'applied'
|
||||
approvedBy?: string
|
||||
approvedDate?: string
|
||||
type: 'time' | 'rate' | 'both'
|
||||
}
|
||||
|
||||
const APPROVAL_THRESHOLDS = {
|
||||
absoluteAmount: 100,
|
||||
percentageChange: 10,
|
||||
}
|
||||
|
||||
export function useTimeAndRateAdjustment() {
|
||||
const { updateTimesheet, getTimesheetById, timesheets } = useTimesheetsCrud()
|
||||
const { user } = useAuth()
|
||||
|
||||
const calculateRequiresApproval = useCallback((
|
||||
originalHours: number,
|
||||
originalRate: number,
|
||||
adjustedHours?: number,
|
||||
adjustedRate?: number
|
||||
): boolean => {
|
||||
const originalTotal = originalHours * originalRate
|
||||
const newHours = adjustedHours ?? originalHours
|
||||
const newRate = adjustedRate ?? originalRate
|
||||
const newTotal = newHours * newRate
|
||||
const difference = Math.abs(newTotal - originalTotal)
|
||||
const percentageChange = originalTotal > 0 ? Math.abs((difference / originalTotal) * 100) : 0
|
||||
|
||||
return (
|
||||
difference > APPROVAL_THRESHOLDS.absoluteAmount ||
|
||||
percentageChange > APPROVAL_THRESHOLDS.percentageChange
|
||||
)
|
||||
}, [])
|
||||
|
||||
const createAdjustmentRecord = useCallback((
|
||||
input: TimeAndRateAdjustmentInput,
|
||||
userId: string,
|
||||
userName: string
|
||||
): AdjustmentRecord => {
|
||||
const originalTotal = input.originalHours * input.originalRate
|
||||
const newHours = input.adjustedHours ?? input.originalHours
|
||||
const newRate = input.adjustedRate ?? input.originalRate
|
||||
const newTotal = newHours * newRate
|
||||
const difference = newTotal - originalTotal
|
||||
const percentageChange = originalTotal > 0 ? (difference / originalTotal) * 100 : 0
|
||||
|
||||
return {
|
||||
id: `adj-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
adjustmentDate: new Date().toISOString(),
|
||||
adjustedBy: userName,
|
||||
adjustedByUserId: userId,
|
||||
previousHours: input.originalHours,
|
||||
newHours,
|
||||
previousRate: input.originalRate,
|
||||
newRate,
|
||||
previousAmount: originalTotal,
|
||||
newAmount: newTotal,
|
||||
difference,
|
||||
percentageChange,
|
||||
reason: input.adjustmentReason,
|
||||
notes: input.notes,
|
||||
requiresApproval: input.approvalRequired,
|
||||
approvalStatus: input.approvalRequired ? 'pending_approval' : 'applied',
|
||||
type: input.adjustmentType,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyAdjustment = useCallback(async (
|
||||
input: TimeAndRateAdjustmentInput
|
||||
): Promise<{ success: boolean; adjustmentId?: string; message: string }> => {
|
||||
try {
|
||||
const currentTimesheet = await getTimesheetById(input.timesheetId)
|
||||
|
||||
if (!currentTimesheet) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Timesheet not found'
|
||||
}
|
||||
}
|
||||
|
||||
const adjustmentRecord = createAdjustmentRecord(
|
||||
input,
|
||||
user?.id || 'unknown',
|
||||
user?.name || 'Unknown User'
|
||||
)
|
||||
|
||||
const updates: any = {
|
||||
adjustments: [
|
||||
...(currentTimesheet.adjustments || []),
|
||||
adjustmentRecord as any
|
||||
]
|
||||
}
|
||||
|
||||
if (!input.approvalRequired) {
|
||||
if (input.adjustedHours !== undefined) {
|
||||
updates.hours = input.adjustedHours
|
||||
}
|
||||
if (input.adjustedRate !== undefined) {
|
||||
updates.rate = input.adjustedRate
|
||||
}
|
||||
if (input.adjustedHours !== undefined || input.adjustedRate !== undefined) {
|
||||
const newHours = input.adjustedHours ?? input.originalHours
|
||||
const newRate = input.adjustedRate ?? input.originalRate
|
||||
updates.amount = newHours * newRate
|
||||
}
|
||||
} else {
|
||||
updates.status = 'pending'
|
||||
}
|
||||
|
||||
await updateTimesheet(input.timesheetId, updates)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
adjustmentId: adjustmentRecord.id,
|
||||
message: input.approvalRequired
|
||||
? 'Adjustment submitted for approval'
|
||||
: 'Adjustment applied successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying adjustment:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to apply adjustment'
|
||||
}
|
||||
}
|
||||
}, [user, createAdjustmentRecord, getTimesheetById, updateTimesheet])
|
||||
|
||||
const approveAdjustment = useCallback(async (
|
||||
timesheetId: string,
|
||||
adjustmentId: string
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const timesheet = await getTimesheetById(timesheetId)
|
||||
|
||||
if (!timesheet) {
|
||||
return { success: false, message: 'Timesheet not found' }
|
||||
}
|
||||
|
||||
const adjustment = (timesheet.adjustments || []).find(adj => adj.id === adjustmentId) as any
|
||||
|
||||
if (!adjustment) {
|
||||
return { success: false, message: 'Adjustment not found' }
|
||||
}
|
||||
|
||||
const updatedAdjustments = (timesheet.adjustments || []).map(adj => {
|
||||
const adjAny = adj as any
|
||||
return adjAny.id === adjustmentId
|
||||
? {
|
||||
...adjAny,
|
||||
approvalStatus: 'approved' as const,
|
||||
approvedBy: user?.name || 'Unknown User',
|
||||
approvedDate: new Date().toISOString()
|
||||
}
|
||||
: adjAny
|
||||
})
|
||||
|
||||
const approvedAdj = updatedAdjustments.find((adj: any) => adj.id === adjustmentId) as any
|
||||
|
||||
await updateTimesheet(timesheetId, {
|
||||
adjustments: updatedAdjustments as any,
|
||||
hours: approvedAdj.newHours,
|
||||
rate: approvedAdj.newRate,
|
||||
amount: approvedAdj.newAmount,
|
||||
status: 'approved'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Adjustment approved and applied'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving adjustment:', error)
|
||||
return { success: false, message: 'Failed to approve adjustment' }
|
||||
}
|
||||
}, [user, getTimesheetById, updateTimesheet])
|
||||
|
||||
const rejectAdjustment = useCallback(async (
|
||||
timesheetId: string,
|
||||
adjustmentId: string,
|
||||
rejectionReason?: string
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const timesheet = await getTimesheetById(timesheetId)
|
||||
|
||||
if (!timesheet) {
|
||||
return { success: false, message: 'Timesheet not found' }
|
||||
}
|
||||
|
||||
const updatedAdjustments = (timesheet.adjustments || []).map(adj => {
|
||||
const adjAny = adj as any
|
||||
return adjAny.id === adjustmentId
|
||||
? {
|
||||
...adjAny,
|
||||
approvalStatus: 'rejected' as const,
|
||||
approvedBy: user?.name || 'Unknown User',
|
||||
approvedDate: new Date().toISOString(),
|
||||
rejectionReason
|
||||
}
|
||||
: adjAny
|
||||
})
|
||||
|
||||
await updateTimesheet(timesheetId, {
|
||||
adjustments: updatedAdjustments as any
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Adjustment rejected'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting adjustment:', error)
|
||||
return { success: false, message: 'Failed to reject adjustment' }
|
||||
}
|
||||
}, [user, getTimesheetById, updateTimesheet])
|
||||
|
||||
const getAdjustmentHistory = useCallback(async (
|
||||
timesheetId: string
|
||||
): Promise<AdjustmentRecord[]> => {
|
||||
try {
|
||||
const timesheet = await getTimesheetById(timesheetId)
|
||||
return (timesheet?.adjustments as any as AdjustmentRecord[]) || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching adjustment history:', error)
|
||||
return []
|
||||
}
|
||||
}, [getTimesheetById])
|
||||
|
||||
const getPendingAdjustments = useCallback((): Array<{
|
||||
timesheetId: string
|
||||
timesheet: any
|
||||
adjustment: AdjustmentRecord
|
||||
}> => {
|
||||
const pending: Array<{
|
||||
timesheetId: string
|
||||
timesheet: any
|
||||
adjustment: AdjustmentRecord
|
||||
}> = []
|
||||
|
||||
timesheets.forEach(timesheet => {
|
||||
const pendingAdjs = (timesheet.adjustments || []).filter(
|
||||
(adj: any) => adj.approvalStatus === 'pending_approval'
|
||||
)
|
||||
pendingAdjs.forEach((adj: any) => {
|
||||
pending.push({
|
||||
timesheetId: timesheet.id,
|
||||
timesheet,
|
||||
adjustment: adj as AdjustmentRecord
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return pending
|
||||
}, [timesheets])
|
||||
|
||||
return {
|
||||
calculateRequiresApproval,
|
||||
applyAdjustment,
|
||||
approveAdjustment,
|
||||
rejectAdjustment,
|
||||
getAdjustmentHistory,
|
||||
getPendingAdjustments,
|
||||
APPROVAL_THRESHOLDS
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user