mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: great, implement stuff from product roadmap
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"dbType": null
|
||||
|
||||
{
|
||||
"templateVersion": 0,
|
||||
"dbType": null
|
||||
}
|
||||
51
src/App.tsx
51
src/App.tsx
@@ -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">
|
||||
|
||||
294
src/components/ContractValidator.tsx
Normal file
294
src/components/ContractValidator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
src/components/CreditNoteGenerator.tsx
Normal file
187
src/components/CreditNoteGenerator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
src/components/PermanentPlacementInvoice.tsx
Normal file
184
src/components/PermanentPlacementInvoice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
291
src/components/ShiftPremiumCalculator.tsx
Normal file
291
src/components/ShiftPremiumCalculator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user