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 React ->

Installation

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

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
titlestring'Contractor Invoices & Timesheets'optional
subtitlestring'Review international contractor hoursoptional
payCyclestring'August 2026 · Bi-Weekly #2'optional
initialDataContractorSubmission[]() => initialSubmissions,optional
classHTMLAttributes['class']optional

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)

  • app/components/blocks/ContractorTimesheetApproval.vue16.6 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref } from 'vue'
    import {
      Calendar,
      Check,
      CheckCheck,
      CheckCircle2,
      Clock,
      FileSpreadsheet,
      Globe,
      RotateCcw,
      Search,
    } from 'lucide-vue-next'
    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.vue'
    import TimesheetKpiCards from './TimesheetKpiCards.vue'
    import TimesheetTableRow from './TimesheetTableRow.vue'
    import type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType } from './timesheet-types'
    import { initialSubmissions } from './timesheet-data'
    
    export type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType }
    
    interface Props {
      title?: string
      subtitle?: string
      payCycle?: string
      initialData?: ContractorSubmission[]
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      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,
    })
    
    const submissions = ref<ContractorSubmission[]>(JSON.parse(JSON.stringify(props.initialData)))
    const searchQuery = ref('')
    const selectedStatus = ref<'all' | 'pending' | 'approved' | 'disputed'>('all')
    const currencyDisplay = ref<'both' | 'usd' | 'local'>('both')
    const selectedContractorId = ref<string | null>(null)
    const isDrawerOpen = ref(false)
    const isApprovingAll = ref(false)
    const bulkApprovalDone = ref(false)
    
    const pendingSubmissions = computed(() => submissions.value.filter((s) => s.status === 'pending'))
    const pendingCount = computed(() => pendingSubmissions.value.length)
    const pendingAmountUsd = computed(() => pendingSubmissions.value.reduce((acc, curr) => acc + curr.grossAmountUsd, 0))
    
    const approvedSubmissions = computed(() => submissions.value.filter((s) => s.status === 'approved'))
    const approvedCount = computed(() => approvedSubmissions.value.length)
    const approvedAmountUsd = computed(() => approvedSubmissions.value.reduce((acc, curr) => acc + curr.grossAmountUsd, 0))
    
    const disputedSubmissions = computed(() => submissions.value.filter((s) => s.status === 'disputed'))
    const disputedCount = computed(() => disputedSubmissions.value.length)
    
    const totalHoursLogged = computed(() => submissions.value.reduce((acc, curr) => acc + curr.hoursLogged, 0))
    
    const scheduledPayoutsTotal = computed(() => 42800.0)
    
    const filteredSubmissions = computed(() => {
      return submissions.value.filter((item) => {
        const matchesStatus = selectedStatus.value === 'all' ? true : item.status === selectedStatus.value
        const query = searchQuery.value.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
      })
    })
    
    const selectedContractor = computed(() => submissions.value.find((c) => c.id === selectedContractorId.value))
    
    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}`
    }
    
    function openDrawer(contractor: ContractorSubmission) {
      selectedContractorId.value = contractor.id
      isDrawerOpen.value = true
    }
    
    function approveInvoice(id: string) {
      const index = submissions.value.findIndex((s) => s.id === id)
      if (index !== -1) {
        submissions.value[index].status = 'approved'
      }
    }
    
    function disputeInvoice(id: string) {
      const index = submissions.value.findIndex((s) => s.id === id)
      if (index !== -1) {
        submissions.value[index].status = 'disputed'
      }
    }
    
    function handleApproveDrawer() {
      if (selectedContractorId.value) approveInvoice(selectedContractorId.value)
      isDrawerOpen.value = false
    }
    
    function handleDisputeDrawer() {
      if (selectedContractorId.value) disputeInvoice(selectedContractorId.value)
      isDrawerOpen.value = false
    }
    
    function approveAllPending() {
      if (pendingCount.value === 0) return
      isApprovingAll.value = true
      setTimeout(() => {
        submissions.value.forEach((s) => {
          if (s.status === 'pending') {
            s.status = 'approved'
          }
        })
        isApprovingAll.value = false
        bulkApprovalDone.value = true
        setTimeout(() => {
          bulkApprovalDone.value = false
        }, 3000)
      }, 400)
    }
    
    function resetData() {
      submissions.value = JSON.parse(JSON.stringify(props.initialData))
      selectedStatus.value = 'all'
      searchQuery.value = ''
      bulkApprovalDone.value = false
    }
    </script>
    
    <template>
      <div data-slot="contractor-timesheet-approval" :class="cn('text-foreground w-full space-y-6', props.class)">
        <!-- Header Section -->
        <div class="border-border/80 flex flex-col gap-4 border-b pb-6 lg:flex-row lg:items-center lg:justify-between">
          <div class="space-y-1.5">
            <div class="flex flex-wrap items-center gap-2">
              <Badge variant="outline" class="border-border text-muted-foreground text-xs font-medium">
                <Globe class="text-primary mr-1 size-3" />
                Global Workforce · HRMS
              </Badge>
              <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
                <Calendar class="text-muted-foreground size-3.5" />
                <span>Pay Cycle:</span>
                <span class="text-foreground font-semibold">{{ payCycle }}</span>
              </div>
              <Badge
                v-if="pendingCount > 0"
                variant="outline"
                class="border-warning/30 bg-warning/10 text-warning text-xs font-medium"
              >
                <span class="bg-warning mr-1.5 size-1.5 animate-pulse rounded-full" />
                {{ pendingCount }} Invoices Pending Review
              </Badge>
              <Badge v-else variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium">
                <CheckCircle2 class="text-success mr-1 size-3" />
                All Invoices Reviewed
              </Badge>
            </div>
            <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">
              {{ title }}
            </h1>
            <p class="text-muted-foreground max-w-3xl text-sm leading-relaxed">
              {{ subtitle }}
            </p>
          </div>
    
          <!-- Action Buttons -->
          <div class="flex flex-wrap items-center gap-2.5 pt-1 lg:pt-0">
            <Button
              variant="outline"
              size="sm"
              class="border-border hover:bg-muted h-9 gap-1.5 text-xs font-medium shadow-xs"
              @click="resetData"
            >
              <RotateCcw class="text-muted-foreground size-3.5" />
              Reset Demo
            </Button>
    
            <Button
              variant="default"
              size="sm"
              :disabled="pendingCount === 0 || isApprovingAll"
              class="h-9 gap-2 text-xs font-medium shadow-xs"
              @click="approveAllPending"
            >
              <template v-if="isApprovingAll">
                <Clock class="size-3.5 animate-spin" />
                Processing Batch...
              </template>
              <template v-else-if="bulkApprovalDone">
                <Check class="text-success size-3.5" />
                Batch Approved!
              </template>
              <template v-else>
                <CheckCheck class="size-3.5" />
                <span>Approve All Pending ({{ formatUsd(pendingAmountUsd) }})</span>
              </template>
            </Button>
          </div>
        </div>
    
        <!-- 4 Billing Overview KPI Cards -->
        <TimesheetKpiCards
          :pending-amount-usd="pendingAmountUsd"
          :pending-count="pendingCount"
          :total-hours-logged="totalHoursLogged"
          :scheduled-payouts-total="scheduledPayoutsTotal"
          :format-usd="formatUsd"
        />
    
        <!-- Filter Toolbar -->
        <Card class="border-border bg-card shadow-xs">
          <CardContent class="p-4">
            <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
              <!-- Search input -->
              <div class="relative max-w-md flex-1">
                <Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
                <input
                  v-model="searchQuery"
                  type="text"
                  placeholder="Search contractor, role, country, or PR..."
                  class="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 class="flex flex-wrap items-center gap-2">
                <!-- Status filter tabs -->
                <div class="border-border bg-muted/40 inline-flex rounded-lg border p-0.5 text-xs">
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="selectedStatus = 'all'"
                  >
                    All ({{ submissions.length }})
                  </button>
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="selectedStatus = 'pending'"
                  >
                    Pending ({{ pendingCount }})
                  </button>
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="selectedStatus = 'approved'"
                  >
                    Approved ({{ approvedCount }})
                  </button>
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="selectedStatus = 'disputed'"
                  >
                    Disputed ({{ disputedCount }})
                  </button>
                </div>
    
                <Separator orientation="vertical" class="hidden h-6 md:block" />
    
                <!-- Currency toggle selector -->
                <div class="border-border bg-muted/40 inline-flex rounded-lg border p-0.5 text-xs">
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="currencyDisplay = 'both'"
                  >
                    Dual FX
                  </button>
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="currencyDisplay = 'usd'"
                  >
                    USD ($)
                  </button>
                  <button
                    type="button"
                    :class="
                      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',
                      )
                    "
                    @click="currencyDisplay = 'local'"
                  >
                    Local
                  </button>
                </div>
              </div>
            </div>
          </CardContent>
        </Card>
    
        <!-- Contractor Invoices & Timesheets Table -->
        <Card class="border-border bg-card overflow-hidden shadow-xs">
          <div class="overflow-x-auto">
            <Table>
              <TableHeader class="bg-muted/40">
                <TableRow class="border-border hover:bg-transparent">
                  <TableHead class="text-muted-foreground min-w-[240px] text-xs font-semibold tracking-wider uppercase">
                    Contractor & Location
                  </TableHead>
                  <TableHead class="text-muted-foreground min-w-[180px] text-xs font-semibold tracking-wider uppercase">
                    Role & Rate
                  </TableHead>
                  <TableHead class="text-muted-foreground min-w-[140px] text-xs font-semibold tracking-wider uppercase">
                    Hours Logged
                  </TableHead>
                  <TableHead class="text-muted-foreground min-w-[200px] text-xs font-semibold tracking-wider uppercase">
                    Invoice Gross Amount
                  </TableHead>
                  <TableHead class="text-muted-foreground min-w-[220px] text-xs font-semibold tracking-wider uppercase">
                    Timesheet Verification
                  </TableHead>
                  <TableHead class="text-muted-foreground min-w-[120px] text-xs font-semibold tracking-wider uppercase">
                    Status
                  </TableHead>
                  <TableHead
                    class="text-muted-foreground min-w-[180px] text-right text-xs font-semibold tracking-wider uppercase"
                  >
                    Actions
                  </TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                <template v-if="filteredSubmissions.length > 0">
                  <TimesheetTableRow
                    v-for="contractor in filteredSubmissions"
                    :key="contractor.id"
                    :contractor="contractor"
                    :currency-display="currencyDisplay"
                    :format-usd="formatUsd"
                    :format-local="formatLocal"
                    @open-drawer="openDrawer"
                    @approve-invoice="approveInvoice"
                    @dispute-invoice="disputeInvoice"
                  />
                </template>
    
                <template v-else>
                  <TableRow>
                    <TableCell colspan="7" class="h-36 text-center">
                      <div class="flex flex-col items-center justify-center gap-1.5">
                        <FileSpreadsheet class="text-muted-foreground/50 size-8" />
                        <p class="text-foreground text-sm font-medium">No invoices match your filter</p>
                        <p class="text-muted-foreground text-xs">
                          Try adjusting your search query or switching the status filter tab.
                        </p>
                      </div>
                    </TableCell>
                  </TableRow>
                </template>
              </TableBody>
            </Table>
          </div>
        </Card>
    
        <!-- Timesheet Detail Drawer / Slide-Over Component -->
        <TimesheetDetailSheet
          :open="isDrawerOpen"
          :selected-contractor="selectedContractor"
          :format-usd="formatUsd"
          :format-local="formatLocal"
          @update:open="isDrawerOpen = $event"
          @approve="handleApproveDrawer"
          @dispute="handleDisputeDrawer"
        />
      </div>
    </template>
    
  • app/components/blocks/TimesheetDetailSheet.vue12.1 kB
  • app/components/blocks/TimesheetKpiCards.vue4.9 kB
  • app/components/blocks/TimesheetTableRow.vue9.2 kB
  • app/components/blocks/timesheet-types.ts1.3 kB
  • app/components/blocks/timesheet-data.ts13.1 kB

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