diff --git a/src/components/TimeAndRateAdjustmentWizard.tsx b/src/components/TimeAndRateAdjustmentWizard.tsx new file mode 100644 index 0000000..0efe543 --- /dev/null +++ b/src/components/TimeAndRateAdjustmentWizard.tsx @@ -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 +} + +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('') + const [adjustedRate, setAdjustedRate] = useState('') + const [adjustmentReason, setAdjustmentReason] = useState('') + const [notes, setNotes] = useState('') + 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 ( + + + + + + Time & Rate Adjustment Wizard + + + Adjust timesheet hours and rates with full audit trail + + + +
+ + + +
+
+
Worker
+
{timesheet.workerName}
+
+
+
Client
+
{timesheet.clientName}
+
+
+
Original Hours
+
{timesheet.hoursWorked}h
+
+
+
Original Rate
+
£{timesheet.rate.toFixed(2)}/h
+
+
+
+ + {currentStep === 0 && ( +
+ +
+ setAdjustmentType('time')} + > +
+ +
+
Time Only
+
+ Adjust the number of hours worked +
+
+
+
+ + setAdjustmentType('rate')} + > +
+ +
+
Rate Only
+
+ Adjust the hourly rate +
+
+
+
+ + setAdjustmentType('both')} + > +
+ +
+
Time & Rate
+
+ Adjust both hours and rate +
+
+
+
+
+
+ )} + + {currentStep === 1 && ( +
+ {(adjustmentType === 'time' || adjustmentType === 'both') && ( +
+ + setAdjustedHours(e.target.value)} + /> +
+ Original: {timesheet.hoursWorked}h + {adjustedHours && ( + timesheet.hoursWorked ? 'text-success' : 'text-warning'}> + {' '}→ {adjustedHours}h ({parseFloat(adjustedHours) > timesheet.hoursWorked ? '+' : ''}{(parseFloat(adjustedHours) - timesheet.hoursWorked).toFixed(2)}h) + + )} +
+
+ )} + + {(adjustmentType === 'rate' || adjustmentType === 'both') && ( +
+ + setAdjustedRate(e.target.value)} + /> +
+ Original: £{timesheet.rate.toFixed(2)}/h + {adjustedRate && ( + timesheet.rate ? 'text-success' : 'text-warning'}> + {' '}→ £{parseFloat(adjustedRate).toFixed(2)}/h ({parseFloat(adjustedRate) > timesheet.rate ? '+' : ''}£{(parseFloat(adjustedRate) - timesheet.rate).toFixed(2)}) + + )} +
+
+ )} + + {(adjustedHours || adjustedRate) && ( + + + +
+
Impact Summary
+
+ Original Total: £{originalTotal.toFixed(2)} +
+
+ New Total: £{newTotal.toFixed(2)} +
+
= 0 ? 'text-success' : 'text-destructive'}`}> + Difference: {difference >= 0 ? '+' : ''}£{difference.toFixed(2)} ({difference >= 0 ? '+' : ''}{percentageChange}%) +
+
+
+
+ )} +
+ )} + + {currentStep === 2 && ( +
+
+ + +
+ +
+ +