Generated by Spark: great, implement stuff from product roadmap

This commit is contained in:
2026-01-18 22:20:24 +00:00
committed by GitHub
parent 8ebb4e62e9
commit 9dea4fcdf7
7 changed files with 1079 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
{
"dbType": null
{
"templateVersion": 0,
"dbType": null
}

View File

@@ -67,6 +67,10 @@ import { OneClickPayroll } from '@/components/OneClickPayroll'
import { RateTemplateManager } from '@/components/RateTemplateManager'
import { CustomReportBuilder } from '@/components/CustomReportBuilder'
import { HolidayPayManager } from '@/components/HolidayPayManager'
import { PermanentPlacementInvoice } from '@/components/PermanentPlacementInvoice'
import { CreditNoteGenerator } from '@/components/CreditNoteGenerator'
import { ShiftPremiumCalculator } from '@/components/ShiftPremiumCalculator'
import { ContractValidator } from '@/components/ContractValidator'
import type {
Timesheet,
Invoice,
@@ -78,10 +82,11 @@ import type {
InvoiceStatus,
ComplianceStatus,
Expense,
ExpenseStatus
ExpenseStatus,
RateCard
} from '@/lib/types'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation'
function App() {
const [currentView, setCurrentView] = useState<View>('dashboard')
@@ -108,6 +113,7 @@ function App() {
const [workers = [], setWorkers] = useKV<Worker[]>('workers', [])
const [complianceDocs = [], setComplianceDocs] = useKV<ComplianceDocument[]>('compliance-docs', [])
const [expenses = [], setExpenses] = useKV<Expense[]>('expenses', [])
const [rateCards = [], setRateCards] = useKV<RateCard[]>('rate-cards', [])
const metrics: DashboardMetrics = {
pendingTimesheets: timesheets.filter(t => t.status === 'pending').length,
@@ -387,6 +393,14 @@ function App() {
toast.error('Expense rejected')
}
const handleCreatePlacementInvoice = (invoice: Invoice) => {
setInvoices(current => [...(current || []), invoice])
}
const handleCreateCreditNote = (creditNote: any, creditInvoice: Invoice) => {
setInvoices(current => [...(current || []), creditInvoice])
}
return (
<div className="flex h-screen bg-background">
<aside className="w-64 border-r border-border bg-card flex flex-col">
@@ -523,6 +537,12 @@ function App() {
active={currentView === 'notification-rules'}
onClick={() => setCurrentView('notification-rules')}
/>
<NavItem
icon={<ShieldCheck size={20} />}
label="Contract Validation"
active={currentView === 'contract-validation'}
onClick={() => setCurrentView('contract-validation')}
/>
</NavGroup>
<NavGroup
@@ -695,6 +715,9 @@ function App() {
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onSendInvoice={handleSendInvoice}
onCreatePlacementInvoice={handleCreatePlacementInvoice}
onCreateCreditNote={handleCreateCreditNote}
rateCards={rateCards}
/>
)}
@@ -809,6 +832,13 @@ function App() {
<HolidayPayManager />
)}
{currentView === 'contract-validation' && (
<ContractValidator
timesheets={timesheets}
rateCards={rateCards}
/>
)}
{currentView === 'roadmap' && (
<RoadmapView />
)}
@@ -1522,9 +1552,12 @@ interface BillingViewProps {
searchQuery: string
setSearchQuery: (query: string) => void
onSendInvoice: (invoiceId: string) => void
onCreatePlacementInvoice: (invoice: Invoice) => void
onCreateCreditNote: (creditNote: any, creditInvoice: Invoice) => void
rateCards: RateCard[]
}
function BillingView({ invoices, searchQuery, setSearchQuery, onSendInvoice }: BillingViewProps) {
function BillingView({ invoices, searchQuery, setSearchQuery, onSendInvoice, onCreatePlacementInvoice, onCreateCreditNote, rateCards }: BillingViewProps) {
const filteredInvoices = invoices.filter(i =>
i.invoiceNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
i.clientName.toLowerCase().includes(searchQuery.toLowerCase())
@@ -1537,10 +1570,14 @@ function BillingView({ invoices, searchQuery, setSearchQuery, onSendInvoice }: B
<h2 className="text-3xl font-semibold tracking-tight">Billing & Invoicing</h2>
<p className="text-muted-foreground mt-1">Manage invoices and track payments</p>
</div>
<Button>
<Plus size={18} className="mr-2" />
Create Invoice
</Button>
<div className="flex gap-2">
<PermanentPlacementInvoice onCreateInvoice={onCreatePlacementInvoice} />
<CreditNoteGenerator invoices={invoices} onCreateCreditNote={onCreateCreditNote} />
<Button>
<Plus size={18} className="mr-2" />
Create Invoice
</Button>
</div>
</div>
<div className="flex items-center gap-4">

View File

@@ -0,0 +1,294 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Warning, CheckCircle, XCircle, ShieldCheck } from '@phosphor-icons/react'
import type { Timesheet, RateCard, ValidationRule } from '@/lib/types'
import { cn } from '@/lib/utils'
interface ContractValidatorProps {
timesheets: Timesheet[]
rateCards: RateCard[]
}
export function ContractValidator({ timesheets, rateCards }: ContractValidatorProps) {
const validateTimesheet = (timesheet: Timesheet): {
isValid: boolean
errors: string[]
warnings: string[]
} => {
const errors: string[] = []
const warnings: string[] = []
const rateCard = rateCards.find(rc => rc.id === timesheet.rateCardId)
if (!rateCard) {
errors.push('No rate card assigned')
return { isValid: false, errors, warnings }
}
if (rateCard.validationRules) {
rateCard.validationRules.forEach(rule => {
const violated = checkRuleViolation(timesheet, rule)
if (violated) {
if (rule.severity === 'error') {
errors.push(rule.message)
} else {
warnings.push(rule.message)
}
}
})
}
if (!timesheet.rate || timesheet.rate < rateCard.standardRate * 0.5) {
errors.push(`Rate £${timesheet.rate || 0} is below minimum allowed (£${rateCard.standardRate * 0.5})`)
}
if (timesheet.rate && timesheet.rate > rateCard.standardRate * 3) {
warnings.push(`Rate £${timesheet.rate} exceeds 3x standard rate`)
}
return {
isValid: errors.length === 0,
errors,
warnings
}
}
const checkRuleViolation = (timesheet: Timesheet, rule: ValidationRule): boolean => {
switch (rule.type) {
case 'max-hours-per-day':
if (timesheet.shifts) {
const maxDailyHours = Math.max(...timesheet.shifts.map(s => s.hours))
return maxDailyHours > rule.value
}
return timesheet.hours / 5 > rule.value
case 'max-hours-per-week':
return timesheet.hours > rule.value
case 'min-break':
if (timesheet.shifts) {
const hasLongShift = timesheet.shifts.some(s => s.hours > 6)
return hasLongShift
}
return false
case 'max-consecutive-days':
return false
default:
return false
}
}
const validatedTimesheets = timesheets.map(ts => ({
timesheet: ts,
validation: validateTimesheet(ts)
}))
const withErrors = validatedTimesheets.filter(v => !v.validation.isValid)
const withWarnings = validatedTimesheets.filter(v => v.validation.isValid && v.validation.warnings.length > 0)
const compliant = validatedTimesheets.filter(v => v.validation.isValid && v.validation.warnings.length === 0)
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-semibold tracking-tight">Contract Validation</h2>
<p className="text-muted-foreground mt-1">Validate timesheets against rate cards and compliance rules</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-l-4 border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<XCircle size={18} className="text-destructive" weight="fill" />
Validation Errors
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">{withErrors.length}</div>
<p className="text-sm text-muted-foreground mt-1">Timesheets blocked from processing</p>
</CardContent>
</Card>
<Card className="border-l-4 border-warning/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Warning size={18} className="text-warning" weight="fill" />
Warnings
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">{withWarnings.length}</div>
<p className="text-sm text-muted-foreground mt-1">Review recommended</p>
</CardContent>
</Card>
<Card className="border-l-4 border-success/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle size={18} className="text-success" weight="fill" />
Compliant
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">{compliant.length}</div>
<p className="text-sm text-muted-foreground mt-1">Ready for processing</p>
</CardContent>
</Card>
</div>
{withErrors.length > 0 && (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<XCircle size={20} className="text-destructive" weight="fill" />
Validation Errors - Action Required
</CardTitle>
<CardDescription>
These timesheets have critical validation errors and cannot be processed
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{withErrors.map(({ timesheet, validation }) => (
<Card key={timesheet.id} className="bg-destructive/5">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold">{timesheet.workerName}</h3>
<Badge variant="destructive">Error</Badge>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Client</p>
<p className="font-medium">{timesheet.clientName}</p>
</div>
<div>
<p className="text-muted-foreground">Week Ending</p>
<p className="font-medium">{new Date(timesheet.weekEnding).toLocaleDateString()}</p>
</div>
<div>
<p className="text-muted-foreground">Hours</p>
<p className="font-medium font-mono">{timesheet.hours}</p>
</div>
</div>
<div className="space-y-1 mt-3">
{validation.errors.map((error, idx) => (
<Alert key={idx} variant="destructive" className="py-2">
<AlertDescription className="text-sm">{error}</AlertDescription>
</Alert>
))}
</div>
</div>
<Button size="sm" variant="outline">
Fix Issues
</Button>
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
)}
{withWarnings.length > 0 && (
<Card className="border-warning">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Warning size={20} className="text-warning" weight="fill" />
Warnings - Review Recommended
</CardTitle>
<CardDescription>
These timesheets have potential issues but can be processed
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{withWarnings.map(({ timesheet, validation }) => (
<Card key={timesheet.id} className="bg-warning/5">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold">{timesheet.workerName}</h3>
<Badge variant="warning">Warning</Badge>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Client</p>
<p className="font-medium">{timesheet.clientName}</p>
</div>
<div>
<p className="text-muted-foreground">Week Ending</p>
<p className="font-medium">{new Date(timesheet.weekEnding).toLocaleDateString()}</p>
</div>
<div>
<p className="text-muted-foreground">Hours</p>
<p className="font-medium font-mono">{timesheet.hours}</p>
</div>
</div>
<div className="space-y-1 mt-3">
{validation.warnings.map((warning, idx) => (
<Alert key={idx} className="py-2 border-warning">
<AlertDescription className="text-sm">{warning}</AlertDescription>
</Alert>
))}
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline">
Review
</Button>
<Button size="sm" style={{ backgroundColor: 'var(--success)', color: 'var(--success-foreground)' }}>
Approve Anyway
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
)}
{compliant.length > 0 && (
<Card className="border-success">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<CheckCircle size={20} className="text-success" weight="fill" />
Compliant Timesheets - Ready to Process
</CardTitle>
<CardDescription>
These timesheets passed all validation checks
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{compliant.slice(0, 5).map(({ timesheet }) => (
<div key={timesheet.id} className="flex items-center justify-between p-3 bg-success/5 rounded-lg">
<div className="flex items-center gap-4">
<CheckCircle size={20} className="text-success" weight="fill" />
<div>
<p className="font-medium">{timesheet.workerName}</p>
<p className="text-sm text-muted-foreground">{timesheet.clientName}</p>
</div>
</div>
<div className="text-right">
<p className="font-mono font-medium">{timesheet.hours}h</p>
<p className="text-sm text-muted-foreground">£{timesheet.amount.toLocaleString()}</p>
</div>
</div>
))}
{compliant.length > 5 && (
<p className="text-sm text-muted-foreground text-center py-2">
+ {compliant.length - 5} more compliant timesheets
</p>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Receipt, ArrowCounterClockwise } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { Invoice, CreditNote } from '@/lib/types'
interface CreditNoteGeneratorProps {
invoices: Invoice[]
onCreateCreditNote: (creditNote: CreditNote, creditInvoice: Invoice) => void
}
export function CreditNoteGenerator({ invoices, onCreateCreditNote }: CreditNoteGeneratorProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedInvoiceId, setSelectedInvoiceId] = useState<string>('')
const [creditAmount, setCreditAmount] = useState('')
const [reason, setReason] = useState('')
const selectedInvoice = invoices.find(inv => inv.id === selectedInvoiceId)
const handleSubmit = () => {
if (!selectedInvoiceId || !creditAmount || !reason) {
toast.error('Please fill in all fields')
return
}
if (!selectedInvoice) return
const amount = parseFloat(creditAmount)
if (amount > selectedInvoice.amount) {
toast.error('Credit amount cannot exceed original invoice amount')
return
}
const creditNote: CreditNote = {
id: `CN-${Date.now()}`,
creditNoteNumber: `CN-${String(Math.floor(Math.random() * 10000)).padStart(5, '0')}`,
originalInvoiceId: selectedInvoiceId,
originalInvoiceNumber: selectedInvoice.invoiceNumber,
clientName: selectedInvoice.clientName,
issueDate: new Date().toISOString().split('T')[0],
amount: amount,
reason: reason,
status: 'draft',
currency: selectedInvoice.currency
}
const creditInvoice: Invoice = {
id: creditNote.id,
invoiceNumber: creditNote.creditNoteNumber,
clientName: selectedInvoice.clientName,
issueDate: creditNote.issueDate,
dueDate: creditNote.issueDate,
amount: -amount,
status: 'credit',
currency: selectedInvoice.currency,
type: 'credit-note',
relatedInvoiceId: selectedInvoiceId,
notes: `Credit note for ${selectedInvoice.invoiceNumber}: ${reason}`,
lineItems: [{
id: `LI-${Date.now()}`,
description: `Credit: ${reason}`,
quantity: 1,
rate: -amount,
amount: -amount
}]
}
onCreateCreditNote(creditNote, creditInvoice)
toast.success(`Credit note ${creditNote.creditNoteNumber} created for ${creditInvoice.currency === 'GBP' ? '£' : '$'}${amount.toLocaleString()}`)
setSelectedInvoiceId('')
setCreditAmount('')
setReason('')
setIsOpen(false)
}
const eligibleInvoices = invoices.filter(inv =>
inv.type !== 'credit-note' &&
(inv.status === 'sent' || inv.status === 'paid')
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<ArrowCounterClockwise size={18} className="mr-2" />
Create Credit Note
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Generate Credit Note</DialogTitle>
<DialogDescription>
Create a credit note to adjust or reverse an invoice
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="cn-invoice">Original Invoice *</Label>
<Select value={selectedInvoiceId} onValueChange={setSelectedInvoiceId}>
<SelectTrigger id="cn-invoice">
<SelectValue placeholder="Select an invoice" />
</SelectTrigger>
<SelectContent>
{eligibleInvoices.map(invoice => (
<SelectItem key={invoice.id} value={invoice.id}>
{invoice.invoiceNumber} - {invoice.clientName} - {invoice.currency === 'GBP' ? '£' : '$'}{invoice.amount.toLocaleString()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedInvoice && (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Invoice Number</p>
<p className="font-medium font-mono">{selectedInvoice.invoiceNumber}</p>
</div>
<div>
<p className="text-muted-foreground">Client</p>
<p className="font-medium">{selectedInvoice.clientName}</p>
</div>
<div>
<p className="text-muted-foreground">Original Amount</p>
<p className="font-semibold font-mono text-lg">
{selectedInvoice.currency === 'GBP' ? '£' : '$'}{selectedInvoice.amount.toLocaleString()}
</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<Badge variant={selectedInvoice.status === 'paid' ? 'success' : 'warning'}>
{selectedInvoice.status}
</Badge>
</div>
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
<Label htmlFor="cn-amount">Credit Amount ({selectedInvoice?.currency || 'GBP'}) *</Label>
<Input
id="cn-amount"
type="number"
step="0.01"
placeholder="0.00"
value={creditAmount}
onChange={(e) => setCreditAmount(e.target.value)}
disabled={!selectedInvoiceId}
/>
{selectedInvoice && creditAmount && parseFloat(creditAmount) > selectedInvoice.amount && (
<p className="text-xs text-destructive">Credit amount cannot exceed original invoice amount</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cn-reason">Reason for Credit *</Label>
<Textarea
id="cn-reason"
placeholder="e.g., Timesheet adjustment, overpayment correction, service issue"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!selectedInvoiceId || !creditAmount || !reason}>
Generate Credit Note
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,184 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { UserPlus, Plus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { Invoice, PlacementDetails } from '@/lib/types'
interface PermanentPlacementInvoiceProps {
onCreateInvoice: (invoice: Invoice) => void
}
export function PermanentPlacementInvoice({ onCreateInvoice }: PermanentPlacementInvoiceProps) {
const [isOpen, setIsOpen] = useState(false)
const [formData, setFormData] = useState({
clientName: '',
candidateName: '',
position: '',
startDate: '',
salary: '',
feePercentage: '20',
guaranteePeriod: '90'
})
const handleSubmit = () => {
if (!formData.clientName || !formData.candidateName || !formData.position || !formData.startDate || !formData.salary) {
toast.error('Please fill in all required fields')
return
}
const salary = parseFloat(formData.salary)
const feePercentage = parseFloat(formData.feePercentage)
const feeAmount = (salary * feePercentage) / 100
const placementDetails: PlacementDetails = {
candidateName: formData.candidateName,
position: formData.position,
startDate: formData.startDate,
salary: salary,
feePercentage: feePercentage,
guaranteePeriod: parseInt(formData.guaranteePeriod)
}
const invoice: Invoice = {
id: `INV-PP-${Date.now()}`,
invoiceNumber: `PP-${String(Math.floor(Math.random() * 10000)).padStart(5, '0')}`,
clientName: formData.clientName,
issueDate: new Date().toISOString().split('T')[0],
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
amount: feeAmount,
status: 'draft',
currency: 'GBP',
type: 'permanent-placement',
placementDetails: placementDetails,
lineItems: [{
id: `LI-${Date.now()}`,
description: `Permanent placement fee: ${formData.candidateName} - ${formData.position}`,
quantity: 1,
rate: feeAmount,
amount: feeAmount
}],
notes: `${feePercentage}% placement fee on annual salary of £${salary.toLocaleString()}. ${formData.guaranteePeriod} day guarantee period.`
}
onCreateInvoice(invoice)
toast.success(`Placement invoice ${invoice.invoiceNumber} created for £${feeAmount.toLocaleString()}`)
setFormData({
clientName: '',
candidateName: '',
position: '',
startDate: '',
salary: '',
feePercentage: '20',
guaranteePeriod: '90'
})
setIsOpen(false)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<UserPlus size={18} className="mr-2" />
Permanent Placement
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Permanent Placement Invoice</DialogTitle>
<DialogDescription>
Generate an invoice for a permanent placement fee
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-2 col-span-2">
<Label htmlFor="pp-client">Client Name *</Label>
<Input
id="pp-client"
placeholder="Enter client name"
value={formData.clientName}
onChange={(e) => setFormData({ ...formData, clientName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-candidate">Candidate Name *</Label>
<Input
id="pp-candidate"
placeholder="Enter candidate name"
value={formData.candidateName}
onChange={(e) => setFormData({ ...formData, candidateName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-position">Position *</Label>
<Input
id="pp-position"
placeholder="e.g. Senior Developer"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-start">Start Date *</Label>
<Input
id="pp-start"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-salary">Annual Salary (£) *</Label>
<Input
id="pp-salary"
type="number"
step="1000"
placeholder="50000"
value={formData.salary}
onChange={(e) => setFormData({ ...formData, salary: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-fee">Fee Percentage *</Label>
<Input
id="pp-fee"
type="number"
step="1"
placeholder="20"
value={formData.feePercentage}
onChange={(e) => setFormData({ ...formData, feePercentage: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pp-guarantee">Guarantee Period (days) *</Label>
<Input
id="pp-guarantee"
type="number"
placeholder="90"
value={formData.guaranteePeriod}
onChange={(e) => setFormData({ ...formData, guaranteePeriod: e.target.value })}
/>
</div>
</div>
{formData.salary && formData.feePercentage && (
<div className="bg-accent/10 rounded-lg p-4 mb-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Calculated Fee:</span>
<span className="text-2xl font-semibold font-mono">
£{((parseFloat(formData.salary) * parseFloat(formData.feePercentage)) / 100).toLocaleString()}
</span>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit}>Create Placement Invoice</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,291 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Clock, Plus, Trash } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { RateCard, ShiftEntry, ShiftType } from '@/lib/types'
interface ShiftPremiumCalculatorProps {
rateCards: RateCard[]
onCalculate: (shifts: ShiftEntry[], totalAmount: number) => void
}
export function ShiftPremiumCalculator({ rateCards, onCalculate }: ShiftPremiumCalculatorProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedRateCardId, setSelectedRateCardId] = useState<string>('')
const [shifts, setShifts] = useState<Array<{
date: string
shiftType: ShiftType
hours: string
}>>([
{ date: '', shiftType: 'standard', hours: '' }
])
const selectedRateCard = rateCards.find(rc => rc.id === selectedRateCardId)
const getShiftRate = (shiftType: ShiftType, baseRate: number, rateCard: RateCard): number => {
switch (shiftType) {
case 'standard':
return baseRate
case 'overtime':
return baseRate * rateCard.overtimeMultiplier
case 'weekend':
return baseRate * rateCard.weekendMultiplier
case 'night':
return baseRate * rateCard.nightMultiplier
case 'holiday':
return baseRate * rateCard.holidayMultiplier
default:
return baseRate
}
}
const calculateShifts = (): ShiftEntry[] => {
if (!selectedRateCard) return []
return shifts
.filter(s => s.date && s.hours)
.map((shift, index) => {
const hours = parseFloat(shift.hours)
const rate = getShiftRate(shift.shiftType, selectedRateCard.standardRate, selectedRateCard)
const amount = hours * rate
return {
id: `SHIFT-${Date.now()}-${index}`,
date: shift.date,
shiftType: shift.shiftType,
hours: hours,
rate: rate,
amount: amount
}
})
}
const addShift = () => {
setShifts([...shifts, { date: '', shiftType: 'standard', hours: '' }])
}
const removeShift = (index: number) => {
setShifts(shifts.filter((_, i) => i !== index))
}
const updateShift = (index: number, field: keyof typeof shifts[0], value: string) => {
const updated = [...shifts]
updated[index] = { ...updated[index], [field]: value }
setShifts(updated)
}
const handleCalculate = () => {
if (!selectedRateCardId) {
toast.error('Please select a rate card')
return
}
const calculatedShifts = calculateShifts()
if (calculatedShifts.length === 0) {
toast.error('Please add at least one valid shift')
return
}
const totalAmount = calculatedShifts.reduce((sum, shift) => sum + shift.amount, 0)
onCalculate(calculatedShifts, totalAmount)
toast.success(`Calculated ${calculatedShifts.length} shifts totaling £${totalAmount.toFixed(2)}`)
setSelectedRateCardId('')
setShifts([{ date: '', shiftType: 'standard', hours: '' }])
setIsOpen(false)
}
const calculatedShifts = selectedRateCard ? calculateShifts() : []
const totalAmount = calculatedShifts.reduce((sum, shift) => sum + shift.amount, 0)
const getShiftTypeInfo = (shiftType: ShiftType) => {
switch (shiftType) {
case 'standard':
return { label: 'Standard', color: 'bg-muted' }
case 'overtime':
return { label: 'Overtime', color: 'bg-warning/20' }
case 'weekend':
return { label: 'Weekend', color: 'bg-info/20' }
case 'night':
return { label: 'Night', color: 'bg-accent/20' }
case 'holiday':
return { label: 'Holiday', color: 'bg-success/20' }
}
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Clock size={18} className="mr-2" />
Calculate with Premiums
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Shift Premium Calculator</DialogTitle>
<DialogDescription>
Calculate timesheet amounts with automatic premium rates for overtime, weekend, night, and holiday shifts
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="spc-ratecard">Rate Card *</Label>
<Select value={selectedRateCardId} onValueChange={setSelectedRateCardId}>
<SelectTrigger id="spc-ratecard">
<SelectValue placeholder="Select a rate card" />
</SelectTrigger>
<SelectContent>
{rateCards.map(rc => (
<SelectItem key={rc.id} value={rc.id}>
{rc.name} - £{rc.standardRate}/hr (OT: {rc.overtimeMultiplier}x, Weekend: {rc.weekendMultiplier}x)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRateCard && (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="grid grid-cols-5 gap-3 text-sm">
<div>
<p className="text-muted-foreground">Standard</p>
<p className="font-semibold font-mono">£{selectedRateCard.standardRate}</p>
</div>
<div>
<p className="text-muted-foreground">Overtime</p>
<p className="font-semibold font-mono">£{(selectedRateCard.standardRate * selectedRateCard.overtimeMultiplier).toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Weekend</p>
<p className="font-semibold font-mono">£{(selectedRateCard.standardRate * selectedRateCard.weekendMultiplier).toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Night</p>
<p className="font-semibold font-mono">£{(selectedRateCard.standardRate * selectedRateCard.nightMultiplier).toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Holiday</p>
<p className="font-semibold font-mono">£{(selectedRateCard.standardRate * selectedRateCard.holidayMultiplier).toFixed(2)}</p>
</div>
</div>
</CardContent>
</Card>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Shifts</Label>
<Button size="sm" variant="outline" onClick={addShift}>
<Plus size={16} className="mr-2" />
Add Shift
</Button>
</div>
{shifts.map((shift, index) => (
<Card key={index}>
<CardContent className="p-4">
<div className="grid grid-cols-12 gap-3 items-end">
<div className="col-span-4 space-y-2">
<Label>Date</Label>
<Input
type="date"
value={shift.date}
onChange={(e) => updateShift(index, 'date', e.target.value)}
/>
</div>
<div className="col-span-4 space-y-2">
<Label>Shift Type</Label>
<Select
value={shift.shiftType}
onValueChange={(value) => updateShift(index, 'shiftType', value as ShiftType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="overtime">Overtime</SelectItem>
<SelectItem value="weekend">Weekend</SelectItem>
<SelectItem value="night">Night</SelectItem>
<SelectItem value="holiday">Holiday</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-3 space-y-2">
<Label>Hours</Label>
<Input
type="number"
step="0.5"
placeholder="8"
value={shift.hours}
onChange={(e) => updateShift(index, 'hours', e.target.value)}
/>
</div>
<div className="col-span-1 flex items-end">
<Button
size="sm"
variant="ghost"
onClick={() => removeShift(index)}
disabled={shifts.length === 1}
>
<Trash size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{calculatedShifts.length > 0 && (
<Card className="border-accent">
<CardHeader>
<CardTitle className="text-base">Calculated Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{calculatedShifts.map((shift, index) => {
const info = getShiftTypeInfo(shift.shiftType)
return (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<Badge variant="outline" className={cn(info.color)}>
{info.label}
</Badge>
<span className="text-muted-foreground">{shift.date}</span>
<span className="font-mono">{shift.hours}h × £{shift.rate.toFixed(2)}</span>
</div>
<span className="font-semibold font-mono">£{shift.amount.toFixed(2)}</span>
</div>
)
})}
<div className="pt-3 border-t border-border flex items-center justify-between">
<span className="font-semibold">Total Amount</span>
<span className="text-2xl font-semibold font-mono">£{totalAmount.toFixed(2)}</span>
</div>
</CardContent>
</Card>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button onClick={handleCalculate} disabled={!selectedRateCardId || calculatedShifts.length === 0}>
Apply Calculation
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,7 @@
export type TimesheetStatus = 'pending' | 'approved' | 'rejected' | 'processing' | 'awaiting-client' | 'awaiting-manager'
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue'
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'credit' | 'cancelled'
export type InvoiceType = 'timesheet' | 'permanent-placement' | 'credit-note' | 'adhoc'
export type ShiftType = 'standard' | 'overtime' | 'weekend' | 'night' | 'holiday'
export type PayrollStatus = 'scheduled' | 'processing' | 'completed' | 'failed'
export type ComplianceStatus = 'valid' | 'expiring' | 'expired'
export type ExpenseStatus = 'pending' | 'approved' | 'rejected' | 'paid'
@@ -24,6 +26,18 @@ export interface Timesheet {
currentApprovalStep?: ApprovalStep
rate?: number
adjustments?: TimesheetAdjustment[]
shifts?: ShiftEntry[]
rateCardId?: string
validationErrors?: string[]
}
export interface ShiftEntry {
id: string
date: string
shiftType: ShiftType
hours: number
rate: number
amount: number
}
export interface ApprovalHistoryEntry {
@@ -59,6 +73,18 @@ export interface Invoice {
lineItems?: InvoiceLineItem[]
notes?: string
paymentTerms?: string
type?: InvoiceType
relatedInvoiceId?: string
placementDetails?: PlacementDetails
}
export interface PlacementDetails {
candidateName: string
position: string
startDate: string
salary: number
feePercentage: number
guaranteePeriod: number
}
export interface InvoiceLineItem {
@@ -200,3 +226,51 @@ export interface QRTimesheetData {
rate: number
signature?: string
}
export interface RateCard {
id: string
name: string
clientName?: string
role?: string
standardRate: number
overtimeMultiplier: number
weekendMultiplier: number
nightMultiplier: number
holidayMultiplier: number
effectiveFrom: string
effectiveTo?: string
validationRules?: ValidationRule[]
}
export interface ValidationRule {
id: string
type: 'max-hours-per-day' | 'max-hours-per-week' | 'min-break' | 'max-consecutive-days'
value: number
severity: 'warning' | 'error'
message: string
}
export interface CreditNote {
id: string
creditNoteNumber: string
originalInvoiceId: string
originalInvoiceNumber: string
clientName: string
issueDate: string
amount: number
reason: string
status: 'draft' | 'issued' | 'applied'
currency: string
}
export interface AuditLogEntry {
id: string
timestamp: string
userId: string
userName: string
action: string
entityType: 'timesheet' | 'invoice' | 'payroll' | 'worker' | 'expense' | 'rate-card'
entityId: string
changes: Record<string, { from: any; to: any }>
ipAddress?: string
}