UIPackage
Menu

Framework

Change language

Boilerplate repo

Contractor Timesheet Approval

blockfinance

Deel/Remote style international contractor invoice approval, hourly timesheet verification against GitHub PRs & deliverables, multi-currency payout calculation, overview KPI metrics, and itemized daily timesheet slide-over drawer.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/contractor-timesheet-approval.json
Named registry:npx shadcn@latest add @uipkge-react/contractor-timesheet-approvalInstalls to:components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
titlestringoptional
subtitlestringoptional
payCyclestringoptional
initialDataContractorSubmission[]optional
classNamestringoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DailyTimesheetEntry
interface DailyTimesheetEntry {
  id: string
  date: string
  day: string
  hours: number
  regularHours: number
  overtimeHours: number
  task: string
  deliverableType: DeliverableType
  referenceId: string
  referenceLabel: string
  commitCount?: number
  verified: boolean
}
ContractorSubmission
interface ContractorSubmission {
  id: string
  name: string
  initials: string
  avatarUrl?: string
  role: string
  country: string
  countryCode: string
  flag: string
  city: string
  contractType: string
  hourlyRateUsd: number
  hourlyRateLocal: number
  currencyCode: string
  currencySymbol: string
  fxRate: number
  hoursLogged: number
  regularHours: number
  overtimeHours: number
  grossAmountLocal: number
  grossAmountUsd: number
  verificationBadge: {
    label: string
    subtext: string
    status: 'verified' | 'manual'
  }
  status: ApprovalStatus
  invoiceNumber: string
  submittedAt: string
  payPeriod: string
  taxCompliance: {
    w8benStatus: 'valid' | 'pending' | 'exempt'
    taxIdMasked: string
    jurisdiction: string
  }
  paymentMethod: {
    type: 'SWIFT' | 'SEPA' | 'Local Wire' | 'ACH'
    accountMasked: string
    bankName: string
  }
  dailyEntries: DailyTimesheetEntry[]
}

Files installed (6)

  • components/blocks/ContractorTimesheetApproval.tsx17.1 kB
    'use client'
    
    import * as React from 'react'
    import {
      Calendar,
      Check,
      CheckCheck,
      CheckCircle2,
      Clock,
      FileSpreadsheet,
      Globe,
      RotateCcw,
      Search,
    } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Card, CardContent } from '@/components/ui/card'
    import { Separator } from '@/components/ui/separator'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    import { TimesheetDetailSheet } from './TimesheetDetailSheet'
    import { TimesheetKpiCards } from './TimesheetKpiCards'
    import { TimesheetTableRow } from './TimesheetTableRow'
    import type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType } from './timesheet-types'
    import { initialSubmissions } from './timesheet-data'
    
    export type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType }
    
    export interface ContractorTimesheetApprovalProps {
      title?: string
      subtitle?: string
      payCycle?: string
      initialData?: ContractorSubmission[]
      className?: string
    }
    
    function formatUsd(val: number): string {
      return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      }).format(val)
    }
    
    function formatLocal(val: number, currencyCode: string, currencySymbol: string): string {
      if (currencyCode === 'JPY') {
        return `${currencySymbol}${new Intl.NumberFormat('ja-JP').format(val)}`
      }
      return `${currencySymbol}${new Intl.NumberFormat('en-US', {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      }).format(val)} ${currencyCode}`
    }
    
    export function ContractorTimesheetApproval({
      title = 'Contractor Invoices & Timesheets',
      subtitle = 'Review international contractor hours, verify commit logs & deliverables, and execute multi-currency invoice approvals.',
      payCycle = 'August 2026 · Bi-Weekly #2',
      initialData = initialSubmissions,
      className,
    }: ContractorTimesheetApprovalProps) {
      const [submissions, setSubmissions] = React.useState<ContractorSubmission[]>(initialData)
      const [searchQuery, setSearchQuery] = React.useState('')
      const [selectedStatus, setSelectedStatus] = React.useState<'all' | 'pending' | 'approved' | 'disputed'>('all')
      const [currencyDisplay, setCurrencyDisplay] = React.useState<'both' | 'usd' | 'local'>('both')
      const [selectedContractorId, setSelectedContractorId] = React.useState<string | null>(null)
      const [isDrawerOpen, setIsDrawerOpen] = React.useState(false)
      const [isApprovingAll, setIsApprovingAll] = React.useState(false)
      const [bulkApprovalDone, setBulkApprovalDone] = React.useState(false)
    
      const pendingSubmissions = React.useMemo(() => submissions.filter((s) => s.status === 'pending'), [submissions])
      const pendingCount = pendingSubmissions.length
      const pendingAmountUsd = React.useMemo(
        () => pendingSubmissions.reduce((acc, curr) => acc + curr.grossAmountUsd, 0),
        [pendingSubmissions],
      )
    
      const approvedSubmissions = React.useMemo(() => submissions.filter((s) => s.status === 'approved'), [submissions])
      const approvedCount = approvedSubmissions.length
    
      const disputedSubmissions = React.useMemo(() => submissions.filter((s) => s.status === 'disputed'), [submissions])
      const disputedCount = disputedSubmissions.length
    
      const totalHoursLogged = React.useMemo(
        () => submissions.reduce((acc, curr) => acc + curr.hoursLogged, 0),
        [submissions],
      )
    
      const scheduledPayoutsTotal = 42800.0
    
      const filteredSubmissions = React.useMemo(() => {
        return submissions.filter((item) => {
          const matchesStatus = selectedStatus === 'all' ? true : item.status === selectedStatus
          const query = searchQuery.trim().toLowerCase()
          if (!query) return matchesStatus
    
          const matchesSearch =
            item.name.toLowerCase().includes(query) ||
            item.role.toLowerCase().includes(query) ||
            item.country.toLowerCase().includes(query) ||
            item.city.toLowerCase().includes(query) ||
            item.invoiceNumber.toLowerCase().includes(query) ||
            item.verificationBadge.label.toLowerCase().includes(query)
    
          return matchesStatus && matchesSearch
        })
      }, [submissions, selectedStatus, searchQuery])
    
      const selectedContractor = React.useMemo(
        () => submissions.find((c) => c.id === selectedContractorId),
        [submissions, selectedContractorId],
      )
    
      const openDrawer = (contractor: ContractorSubmission) => {
        setSelectedContractorId(contractor.id)
        setIsDrawerOpen(true)
      }
    
      const approveInvoice = (id: string) => {
        setSubmissions((prev) => prev.map((item) => (item.id === id ? { ...item, status: 'approved' } : item)))
      }
    
      const disputeInvoice = (id: string) => {
        setSubmissions((prev) => prev.map((item) => (item.id === id ? { ...item, status: 'disputed' } : item)))
      }
    
      const handleApproveDrawer = () => {
        if (selectedContractorId) approveInvoice(selectedContractorId)
        setIsDrawerOpen(false)
      }
    
      const handleDisputeDrawer = () => {
        if (selectedContractorId) disputeInvoice(selectedContractorId)
        setIsDrawerOpen(false)
      }
    
      const approveAllPending = () => {
        if (pendingCount === 0) return
        setIsApprovingAll(true)
        setTimeout(() => {
          setSubmissions((prev) => prev.map((item) => (item.status === 'pending' ? { ...item, status: 'approved' } : item)))
          setIsApprovingAll(false)
          setBulkApprovalDone(true)
          setTimeout(() => {
            setBulkApprovalDone(false)
          }, 3000)
        }, 400)
      }
    
      const resetData = () => {
        setSubmissions(JSON.parse(JSON.stringify(initialData)))
        setSelectedStatus('all')
        setSearchQuery('')
        setBulkApprovalDone(false)
      }
    
      return (
        <div data-slot="contractor-timesheet-approval" className={cn('text-foreground w-full space-y-6', className)}>
          {/* Header Section */}
          <div className="border-border/80 flex flex-col gap-4 border-b pb-6 lg:flex-row lg:items-center lg:justify-between">
            <div className="space-y-1.5">
              <div className="flex flex-wrap items-center gap-2">
                <Badge variant="outline" className="border-border text-muted-foreground text-xs font-medium">
                  <Globe className="text-primary mr-1 size-3" />
                  Global Workforce · HRMS
                </Badge>
                <div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
                  <Calendar className="text-muted-foreground size-3.5" />
                  <span>Pay Cycle:</span>
                  <span className="text-foreground font-semibold">{payCycle}</span>
                </div>
                {pendingCount > 0 ? (
                  <Badge variant="outline" className="border-warning/30 bg-warning/10 text-warning text-xs font-medium">
                    <span className="bg-warning mr-1.5 size-1.5 animate-pulse rounded-full" />
                    {pendingCount} Invoices Pending Review
                  </Badge>
                ) : (
                  <Badge variant="outline" className="border-success/30 bg-success/10 text-success text-xs font-medium">
                    <CheckCircle2 className="text-success mr-1 size-3" />
                    All Invoices Reviewed
                  </Badge>
                )}
              </div>
              <h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
              <p className="text-muted-foreground max-w-3xl text-sm leading-relaxed">{subtitle}</p>
            </div>
    
            {/* Action Buttons */}
            <div className="flex flex-wrap items-center gap-2.5 pt-1 lg:pt-0">
              <Button
                variant="outline"
                size="sm"
                className="border-border hover:bg-muted h-9 gap-1.5 text-xs font-medium shadow-xs"
                onClick={resetData}
              >
                <RotateCcw className="text-muted-foreground size-3.5" />
                Reset Demo
              </Button>
    
              <Button
                variant="default"
                size="sm"
                disabled={pendingCount === 0 || isApprovingAll}
                className="h-9 gap-2 text-xs font-medium shadow-xs"
                onClick={approveAllPending}
              >
                {isApprovingAll ? (
                  <>
                    <Clock className="size-3.5 animate-spin" />
                    Processing Batch...
                  </>
                ) : bulkApprovalDone ? (
                  <>
                    <Check className="text-success size-3.5" />
                    Batch Approved!
                  </>
                ) : (
                  <>
                    <CheckCheck className="size-3.5" />
                    <span>Approve All Pending ({formatUsd(pendingAmountUsd)})</span>
                  </>
                )}
              </Button>
            </div>
          </div>
    
          {/* 4 Billing Overview KPI Cards */}
          <TimesheetKpiCards
            pendingAmountUsd={pendingAmountUsd}
            pendingCount={pendingCount}
            totalHoursLogged={totalHoursLogged}
            scheduledPayoutsTotal={scheduledPayoutsTotal}
            formatUsd={formatUsd}
          />
    
          {/* Filter Toolbar */}
          <Card className="border-border bg-card shadow-xs">
            <CardContent className="p-4">
              <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                {/* Search input */}
                <div className="relative max-w-md flex-1">
                  <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
                  <input
                    value={searchQuery}
                    onChange={(e) => setSearchQuery(e.target.value)}
                    type="text"
                    placeholder="Search contractor, role, country, or PR..."
                    className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border py-1.5 pr-3 pl-9 text-xs focus-visible:ring-2 focus-visible:outline-hidden"
                  />
                </div>
    
                {/* Filter buttons & Currency Toggle */}
                <div className="flex flex-wrap items-center gap-2">
                  {/* Status filter tabs */}
                  <div className="border-border bg-muted/40 inline-flex rounded-lg border p-0.5 text-xs">
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2.5 py-1 font-medium transition-colors',
                        selectedStatus === 'all'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setSelectedStatus('all')}
                    >
                      All ({submissions.length})
                    </button>
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2.5 py-1 font-medium transition-colors',
                        selectedStatus === 'pending'
                          ? 'bg-background text-warning text-warning shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setSelectedStatus('pending')}
                    >
                      Pending ({pendingCount})
                    </button>
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2.5 py-1 font-medium transition-colors',
                        selectedStatus === 'approved'
                          ? 'bg-background text-success text-success shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setSelectedStatus('approved')}
                    >
                      Approved ({approvedCount})
                    </button>
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2.5 py-1 font-medium transition-colors',
                        selectedStatus === 'disputed'
                          ? 'bg-background text-destructive text-destructive shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setSelectedStatus('disputed')}
                    >
                      Disputed ({disputedCount})
                    </button>
                  </div>
    
                  <Separator orientation="vertical" className="hidden h-6 md:block" />
    
                  {/* Currency toggle selector */}
                  <div className="border-border bg-muted/40 inline-flex rounded-lg border p-0.5 text-xs">
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2 py-1 font-medium transition-colors',
                        currencyDisplay === 'both'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setCurrencyDisplay('both')}
                    >
                      Dual FX
                    </button>
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2 py-1 font-medium transition-colors',
                        currencyDisplay === 'usd'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setCurrencyDisplay('usd')}
                    >
                      USD ($)
                    </button>
                    <button
                      type="button"
                      className={cn(
                        'rounded-md px-2 py-1 font-medium transition-colors',
                        currencyDisplay === 'local'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )}
                      onClick={() => setCurrencyDisplay('local')}
                    >
                      Local
                    </button>
                  </div>
                </div>
              </div>
            </CardContent>
          </Card>
    
          {/* Contractor Invoices & Timesheets Table */}
          <Card className="border-border bg-card overflow-hidden shadow-xs">
            <div className="overflow-x-auto">
              <Table>
                <TableHeader className="bg-muted/40">
                  <TableRow className="border-border hover:bg-transparent">
                    <TableHead className="text-muted-foreground min-w-[240px] text-xs font-semibold tracking-wider uppercase">
                      Contractor & Location
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[180px] text-xs font-semibold tracking-wider uppercase">
                      Role & Rate
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[140px] text-xs font-semibold tracking-wider uppercase">
                      Hours Logged
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[200px] text-xs font-semibold tracking-wider uppercase">
                      Invoice Gross Amount
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[220px] text-xs font-semibold tracking-wider uppercase">
                      Timesheet Verification
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[120px] text-xs font-semibold tracking-wider uppercase">
                      Status
                    </TableHead>
                    <TableHead className="text-muted-foreground min-w-[180px] text-right text-xs font-semibold tracking-wider uppercase">
                      Actions
                    </TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filteredSubmissions.length > 0 ? (
                    filteredSubmissions.map((contractor) => (
                      <TimesheetTableRow
                        key={contractor.id}
                        contractor={contractor}
                        currencyDisplay={currencyDisplay}
                        formatUsd={formatUsd}
                        formatLocal={formatLocal}
                        onOpenDrawer={openDrawer}
                        onApproveInvoice={approveInvoice}
                        onDisputeInvoice={disputeInvoice}
                      />
                    ))
                  ) : (
                    <TableRow>
                      <TableCell colSpan={7} className="h-36 text-center">
                        <div className="flex flex-col items-center justify-center gap-1.5">
                          <FileSpreadsheet className="text-muted-foreground/50 size-8" />
                          <p className="text-foreground text-sm font-medium">No invoices match your filter</p>
                          <p className="text-muted-foreground text-xs">
                            Try adjusting your search query or switching the status filter tab.
                          </p>
                        </div>
                      </TableCell>
                    </TableRow>
                  )}
                </TableBody>
              </Table>
            </div>
          </Card>
    
          {/* Timesheet Detail Drawer / Slide-Over Component */}
          <TimesheetDetailSheet
            open={isDrawerOpen}
            onOpenChange={setIsDrawerOpen}
            selectedContractor={selectedContractor}
            formatUsd={formatUsd}
            formatLocal={formatLocal}
            onApprove={handleApproveDrawer}
            onDispute={handleDisputeDrawer}
          />
        </div>
      )
    }
    
  • components/blocks/TimesheetDetailSheet.tsx14 kB
  • components/blocks/TimesheetKpiCards.tsx5.3 kB
  • components/blocks/TimesheetTableRow.tsx10.1 kB
  • components/blocks/timesheet-types.ts1.3 kB
  • components/blocks/timesheet-data.ts13.1 kB

Raw manifest:https://uipkge.dev/r/react/contractor-timesheet-approval.json