From 9dea4fcdf785cfc440919b9403c083e9e9216a99 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 22:20:24 +0000 Subject: [PATCH] Generated by Spark: great, implement stuff from product roadmap --- spark.meta.json | 7 +- src/App.tsx | 51 +++- src/components/ContractValidator.tsx | 294 +++++++++++++++++++ src/components/CreditNoteGenerator.tsx | 187 ++++++++++++ src/components/PermanentPlacementInvoice.tsx | 184 ++++++++++++ src/components/ShiftPremiumCalculator.tsx | 291 ++++++++++++++++++ src/lib/types.ts | 76 ++++- 7 files changed, 1079 insertions(+), 11 deletions(-) create mode 100644 src/components/ContractValidator.tsx create mode 100644 src/components/CreditNoteGenerator.tsx create mode 100644 src/components/PermanentPlacementInvoice.tsx create mode 100644 src/components/ShiftPremiumCalculator.tsx diff --git a/spark.meta.json b/spark.meta.json index 1d49b2a..fd74d91 100644 --- a/spark.meta.json +++ b/spark.meta.json @@ -1,3 +1,4 @@ -{ - "dbType": null - +{ + "templateVersion": 0, + "dbType": null +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d1f07ff..9cbac5e 100644 --- a/src/App.tsx +++ b/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('dashboard') @@ -108,6 +113,7 @@ function App() { const [workers = [], setWorkers] = useKV('workers', []) const [complianceDocs = [], setComplianceDocs] = useKV('compliance-docs', []) const [expenses = [], setExpenses] = useKV('expenses', []) + const [rateCards = [], setRateCards] = useKV('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 (
- +
+ + + +
diff --git a/src/components/ContractValidator.tsx b/src/components/ContractValidator.tsx new file mode 100644 index 0000000..1892b71 --- /dev/null +++ b/src/components/ContractValidator.tsx @@ -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 ( +
+
+

Contract Validation

+

Validate timesheets against rate cards and compliance rules

+
+ +
+ + + + + Validation Errors + + + +
{withErrors.length}
+

Timesheets blocked from processing

+
+
+ + + + + + Warnings + + + +
{withWarnings.length}
+

Review recommended

+
+
+ + + + + + Compliant + + + +
{compliant.length}
+

Ready for processing

+
+
+
+ + {withErrors.length > 0 && ( + + + + + Validation Errors - Action Required + + + These timesheets have critical validation errors and cannot be processed + + + + {withErrors.map(({ timesheet, validation }) => ( + + +
+
+
+

{timesheet.workerName}

+ Error +
+
+
+

Client

+

{timesheet.clientName}

+
+
+

Week Ending

+

{new Date(timesheet.weekEnding).toLocaleDateString()}

+
+
+

Hours

+

{timesheet.hours}

+
+
+
+ {validation.errors.map((error, idx) => ( + + {error} + + ))} +
+
+ +
+
+
+ ))} +
+
+ )} + + {withWarnings.length > 0 && ( + + + + + Warnings - Review Recommended + + + These timesheets have potential issues but can be processed + + + + {withWarnings.map(({ timesheet, validation }) => ( + + +
+
+
+

{timesheet.workerName}

+ Warning +
+
+
+

Client

+

{timesheet.clientName}

+
+
+

Week Ending

+

{new Date(timesheet.weekEnding).toLocaleDateString()}

+
+
+

Hours

+

{timesheet.hours}

+
+
+
+ {validation.warnings.map((warning, idx) => ( + + {warning} + + ))} +
+
+
+ + +
+
+
+
+ ))} +
+
+ )} + + {compliant.length > 0 && ( + + + + + Compliant Timesheets - Ready to Process + + + These timesheets passed all validation checks + + + +
+ {compliant.slice(0, 5).map(({ timesheet }) => ( +
+
+ +
+

{timesheet.workerName}

+

{timesheet.clientName}

+
+
+
+

{timesheet.hours}h

+

£{timesheet.amount.toLocaleString()}

+
+
+ ))} + {compliant.length > 5 && ( +

+ + {compliant.length - 5} more compliant timesheets +

+ )} +
+
+
+ )} +
+ ) +} diff --git a/src/components/CreditNoteGenerator.tsx b/src/components/CreditNoteGenerator.tsx new file mode 100644 index 0000000..23c1a2e --- /dev/null +++ b/src/components/CreditNoteGenerator.tsx @@ -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('') + 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 ( + + + + + + + Generate Credit Note + + Create a credit note to adjust or reverse an invoice + + +
+
+ + +
+ + {selectedInvoice && ( + + +
+
+

Invoice Number

+

{selectedInvoice.invoiceNumber}

+
+
+

Client

+

{selectedInvoice.clientName}

+
+
+

Original Amount

+

+ {selectedInvoice.currency === 'GBP' ? '£' : '$'}{selectedInvoice.amount.toLocaleString()} +

+
+
+

Status

+ + {selectedInvoice.status} + +
+
+
+
+ )} + +
+ + setCreditAmount(e.target.value)} + disabled={!selectedInvoiceId} + /> + {selectedInvoice && creditAmount && parseFloat(creditAmount) > selectedInvoice.amount && ( +

Credit amount cannot exceed original invoice amount

+ )} +
+ +
+ +