Contractor Timesheet Approval
blockfinanceDeel/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.
Installation
$pnpm dlx uipkge-ng@latest add contractor-timesheet-approval$npx uipkge-ng@latest add contractor-timesheet-approval$yarn dlx uipkge-ng@latest add contractor-timesheet-approval$bunx uipkge-ng@latest add contractor-timesheet-approvalManifest:
https://uipkge.dev/r/angular/contractor-timesheet-approval.jsonInstalls to:src/app/components/blocks/contractor-timesheet-approval/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DailyTimesheetEntryinterface 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
}ContractorSubmissioninterface 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[]
}npm dependencies
Files installed (7)
src/app/components/blocks/contractor-timesheet-approval/contractor-timesheet-approval.component.ts18.7 kB
// 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. Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { Calendar, Check, CheckCheck, CheckCircle2, Clock, FileSpreadsheet, Globe, LucideAngularModule, RotateCcw, Search, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTimesheetDetailSheetComponent } from './timesheet-detail-sheet.component' import { UiTimesheetKpiCardsComponent } from './timesheet-kpi-cards.component' import { UiTimesheetTableRowComponent } from './timesheet-table-row.component' import { initialSubmissions } from './timesheet-data' import type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType } from './timesheet-types' export type { ApprovalStatus, ContractorSubmission, DailyTimesheetEntry, DeliverableType } export type TimesheetStatusFilter = 'all' | 'pending' | 'approved' | 'disputed' export type CurrencyDisplay = 'both' | 'usd' | 'local' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-contractor-timesheet-approval, [ui-contractor-timesheet-approval]', standalone: true, // The block renders its own root <div>: keep this wrapper out of the layout. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiSeparatorComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTimesheetDetailSheetComponent, UiTimesheetKpiCardsComponent, UiTimesheetTableRowComponent, ], template: ` <div data-slot="contractor-timesheet-approval" [class]="rootClass"> <!-- 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"> <span ui-badge variant="outline" class="border-border text-muted-foreground text-xs font-medium"> <lucide-icon [img]="Globe" class="text-primary mr-1 size-3" /> Global Workforce · HRMS </span> <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium"> <lucide-icon [img]="Calendar" class="text-muted-foreground size-3.5" /> <span>Pay Cycle:</span> <span class="text-foreground font-semibold">{{ payCycle }}</span> </div> @if (pendingCount() > 0) { <span ui-badge 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"></span> {{ pendingCount() }} Invoices Pending Review </span> } @else { <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium"> <lucide-icon [img]="CheckCircle2" class="text-success mr-1 size-3" /> All Invoices Reviewed </span> } </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 ui-button variant="outline" size="sm" class="border-border hover:bg-muted h-9 gap-1.5 text-xs font-medium shadow-xs" (click)="resetData()" > <lucide-icon [img]="RotateCcw" class="text-muted-foreground size-3.5" /> Reset Demo </button> <button ui-button variant="default" size="sm" [disabled]="pendingCount() === 0 || isApprovingAll()" class="h-9 gap-2 text-xs font-medium shadow-xs" (click)="approveAllPending()" > @if (isApprovingAll()) { <lucide-icon [img]="Clock" class="size-3.5 animate-spin" /> Processing Batch... } @else if (bulkApprovalDone()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> Batch Approved! } @else { <lucide-icon [img]="CheckCheck" class="size-3.5" /> <span>Approve All Pending ({{ formatUsd(pendingAmountUsd()) }})</span> } </button> </div> </div> <!-- 4 Billing Overview KPI Cards --> <ui-timesheet-kpi-cards [pendingAmountUsd]="pendingAmountUsd()" [pendingCount]="pendingCount()" [totalHoursLogged]="totalHoursLogged()" [scheduledPayoutsTotal]="scheduledPayoutsTotal()" [formatUsd]="formatUsdFn" /> <!-- Filter Toolbar --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-content 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"> <lucide-icon [img]="Search" class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <input [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value)" 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]="statusTabClass('all')" (click)="selectedStatus.set('all')"> All ({{ submissions().length }}) </button> <button type="button" [class]="statusTabClass('pending')" (click)="selectedStatus.set('pending')"> Pending ({{ pendingCount() }}) </button> <button type="button" [class]="statusTabClass('approved')" (click)="selectedStatus.set('approved')"> Approved ({{ approvedCount() }}) </button> <button type="button" [class]="statusTabClass('disputed')" (click)="selectedStatus.set('disputed')"> Disputed ({{ disputedCount() }}) </button> </div> <ui-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]="currencyTabClass('both')" (click)="currencyDisplay.set('both')"> Dual FX </button> <button type="button" [class]="currencyTabClass('usd')" (click)="currencyDisplay.set('usd')"> USD ($) </button> <button type="button" [class]="currencyTabClass('local')" (click)="currencyDisplay.set('local')"> Local </button> </div> </div> </div> </div> </div> <!-- Contractor Invoices & Timesheets Table --> <div ui-card class="border-border bg-card overflow-hidden shadow-xs"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header class="bg-muted/40"> <tr ui-table-row class="border-border hover:bg-transparent"> <th ui-table-head class="text-muted-foreground min-w-[240px] text-xs font-semibold tracking-wider uppercase" > Contractor & Location </th> <th ui-table-head class="text-muted-foreground min-w-[180px] text-xs font-semibold tracking-wider uppercase" > Role & Rate </th> <th ui-table-head class="text-muted-foreground min-w-[140px] text-xs font-semibold tracking-wider uppercase" > Hours Logged </th> <th ui-table-head class="text-muted-foreground min-w-[200px] text-xs font-semibold tracking-wider uppercase" > Invoice Gross Amount </th> <th ui-table-head class="text-muted-foreground min-w-[220px] text-xs font-semibold tracking-wider uppercase" > Timesheet Verification </th> <th ui-table-head class="text-muted-foreground min-w-[120px] text-xs font-semibold tracking-wider uppercase" > Status </th> <th ui-table-head class="text-muted-foreground min-w-[180px] text-right text-xs font-semibold tracking-wider uppercase" > Actions </th> </tr> </thead> <tbody ui-table-body> @if (filteredSubmissions().length > 0) { @for (contractor of filteredSubmissions(); track contractor.id) { <tr ui-timesheet-table-row class="border-border hover:bg-muted/30 transition-colors" [contractor]="contractor" [currencyDisplay]="currencyDisplay()" [formatUsd]="formatUsdFn" [formatLocal]="formatLocalFn" (openDrawer)="openDrawer($event)" (approveInvoice)="approveInvoice($event)" (disputeInvoice)="disputeInvoice($event)" ></tr> } } @else { <tr ui-table-row> <td ui-table-cell colspan="7" class="h-36 text-center"> <div class="flex flex-col items-center justify-center gap-1.5"> <lucide-icon [img]="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> </td> </tr> } </tbody> </table> </div> </div> <!-- Timesheet Detail Drawer / Slide-Over Component --> <ui-timesheet-detail-sheet [open]="isDrawerOpen()" [selectedContractor]="selectedContractor()" [formatUsd]="formatUsdFn" [formatLocal]="formatLocalFn" (openChange)="isDrawerOpen.set($event)" (approve)="handleApproveDrawer()" (dispute)="handleDisputeDrawer()" /> </div> `, }) export class UiContractorTimesheetApprovalComponent { protected readonly Calendar = Calendar protected readonly Check = Check protected readonly CheckCheck = CheckCheck protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly FileSpreadsheet = FileSpreadsheet protected readonly Globe = Globe protected readonly RotateCcw = RotateCcw protected readonly Search = Search @Input() title = 'Contractor Invoices & Timesheets' @Input() subtitle = 'Review international contractor hours, verify commit logs & deliverables, and execute multi-currency invoice approvals.' @Input() payCycle = 'August 2026 · Bi-Weekly #2' @Input() set initialData(v: ContractorSubmission[] | undefined) { if (v) this.submissions.set(structuredClone(v)) } @Input('class') className?: string readonly submissions = signal<ContractorSubmission[]>(structuredClone(initialSubmissions)) readonly searchQuery = signal('') readonly selectedStatus = signal<TimesheetStatusFilter>('all') readonly currencyDisplay = signal<CurrencyDisplay>('both') readonly selectedContractorId = signal<string | null>(null) readonly isDrawerOpen = signal(false) readonly isApprovingAll = signal(false) readonly bulkApprovalDone = signal(false) private bulkDoneTimer: ReturnType<typeof setTimeout> | undefined private approveAllTimer: ReturnType<typeof setTimeout> | undefined constructor() { inject(DestroyRef).onDestroy(() => { clearTimeout(this.bulkDoneTimer) clearTimeout(this.approveAllTimer) }) } readonly pendingSubmissions = computed(() => this.submissions().filter((s) => s.status === 'pending')) readonly pendingCount = computed(() => this.pendingSubmissions().length) readonly pendingAmountUsd = computed(() => this.pendingSubmissions().reduce((acc, curr) => acc + curr.grossAmountUsd, 0), ) readonly approvedSubmissions = computed(() => this.submissions().filter((s) => s.status === 'approved')) readonly approvedCount = computed(() => this.approvedSubmissions().length) readonly disputedCount = computed(() => this.submissions().filter((s) => s.status === 'disputed').length) readonly totalHoursLogged = computed(() => this.submissions().reduce((acc, curr) => acc + curr.hoursLogged, 0)) readonly scheduledPayoutsTotal = computed(() => 42800.0) readonly filteredSubmissions = computed(() => { const status = this.selectedStatus() const query = this.searchQuery().trim().toLowerCase() return this.submissions().filter((item) => { const matchesStatus = status === 'all' ? true : item.status === status 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 }) }) readonly selectedContractor = computed(() => this.submissions().find((c) => c.id === this.selectedContractorId())) // Bound function references for the row + sheet inputs (same shape as the Vue function props). readonly formatUsdFn = (val: number): string => this.formatUsd(val) readonly formatLocalFn = (val: number, currencyCode: string, currencySymbol: string): string => this.formatLocal(val, currencyCode, currencySymbol) get rootClass(): string { return cn('text-foreground w-full space-y-6', this.className) } statusTabClass(status: TimesheetStatusFilter): string { const active = this.selectedStatus() === status const activeTone = status === 'pending' ? 'bg-background text-warning text-warning shadow-xs' : status === 'approved' ? 'bg-background text-success text-success shadow-xs' : status === 'disputed' ? 'bg-background text-destructive text-destructive shadow-xs' : 'bg-background text-foreground shadow-xs' return cn( 'rounded-md px-2.5 py-1 font-medium transition-colors', active ? activeTone : 'text-muted-foreground hover:text-foreground', ) } currencyTabClass(mode: CurrencyDisplay): string { return cn( 'rounded-md px-2 py-1 font-medium transition-colors', this.currencyDisplay() === mode ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } formatUsd(val: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(val) } 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}` } openDrawer(contractor: ContractorSubmission): void { this.selectedContractorId.set(contractor.id) this.isDrawerOpen.set(true) } approveInvoice(id: string): void { this.submissions.update((prev) => prev.map((s) => (s.id === id ? { ...s, status: 'approved' as const } : s))) } disputeInvoice(id: string): void { this.submissions.update((prev) => prev.map((s) => (s.id === id ? { ...s, status: 'disputed' as const } : s))) } handleApproveDrawer(): void { const id = this.selectedContractorId() if (id) this.approveInvoice(id) this.isDrawerOpen.set(false) } handleDisputeDrawer(): void { const id = this.selectedContractorId() if (id) this.disputeInvoice(id) this.isDrawerOpen.set(false) } approveAllPending(): void { if (this.pendingCount() === 0) return this.isApprovingAll.set(true) clearTimeout(this.approveAllTimer) this.approveAllTimer = setTimeout(() => { this.submissions.update((prev) => prev.map((s) => (s.status === 'pending' ? { ...s, status: 'approved' as const } : s)), ) this.isApprovingAll.set(false) this.bulkApprovalDone.set(true) clearTimeout(this.bulkDoneTimer) this.bulkDoneTimer = setTimeout(() => { this.bulkApprovalDone.set(false) }, 3000) }, 400) } resetData(): void { this.submissions.set(structuredClone(initialSubmissions)) this.selectedStatus.set('all') this.searchQuery.set('') this.bulkApprovalDone.set(false) } }src/app/components/blocks/contractor-timesheet-approval/timesheet-detail-sheet.component.ts14 kB
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core' import { Check, CheckCircle2, FileCheck, FileCode, GitPullRequest, LucideAngularModule, ShieldCheck, XCircle, } from 'lucide-angular' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiSheetCloseComponent, UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetFooterComponent, UiSheetHeaderComponent, UiSheetTitleComponent, } from '@/ui/sheet/sheet.component' import type { ContractorSubmission } from './timesheet-types' /** Itemized daily timesheet slide-over drawer: verification callout, day-by-day activity log, compliance box. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-timesheet-detail-sheet, [ui-timesheet-detail-sheet]', standalone: true, host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiSheetCloseComponent, UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetFooterComponent, UiSheetHeaderComponent, UiSheetTitleComponent, ], template: ` <div ui-sheet [open]="open" (openChange)="openChange.emit($event)"> <div ui-sheet-content side="right" class="bg-background text-foreground flex w-full flex-col overflow-y-auto p-0 sm:max-w-xl md:max-w-2xl" > @if (selectedContractor; as contractor) { <!-- Drawer Header --> <div ui-sheet-header class="border-border/80 space-y-3 border-b px-6 py-5 text-left"> <div class="flex items-start justify-between gap-4"> <div class="flex items-center gap-3"> <ui-avatar class="border-border size-12 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-sm font-bold"> {{ contractor.initials }} </ui-avatar-fallback> </ui-avatar> <div> <div class="flex items-center gap-2"> <h2 ui-sheet-title class="text-foreground text-lg font-bold"> {{ contractor.name }} </h2> <span class="text-base" [attr.title]="contractor.country"> {{ contractor.flag }} </span> </div> <p ui-sheet-description class="text-muted-foreground text-xs"> {{ contractor.role }} · {{ contractor.city }} </p> </div> </div> <!-- Status Badge in Header --> @if (contractor.status === 'pending') { <span ui-badge variant="outline" class="border-warning/30 bg-warning/10 text-warning shrink-0 text-xs font-medium" > <span class="bg-warning mr-1.5 size-1.5 rounded-full"></span> Pending Review </span> } @else if (contractor.status === 'approved') { <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success shrink-0 text-xs font-medium" > <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> Approved </span> } @else { <span ui-badge variant="outline" class="border-destructive/30 bg-destructive/10 text-destructive shrink-0 text-xs font-medium" > <lucide-icon [img]="XCircle" class="text-destructive mr-1 size-3" /> Disputed </span> } </div> <!-- Header Quick Stats Grid --> <div class="grid grid-cols-3 gap-2 pt-2 text-xs"> <div class="border-border bg-muted/40 rounded-lg border p-2.5"> <span class="text-muted-foreground block">Invoice Gross</span> <span class="text-foreground text-sm font-bold tabular-nums"> {{ formatUsd(contractor.grossAmountUsd) }} </span> <span class="text-muted-foreground/70 block text-xs tabular-nums"> {{ formatLocal(contractor.grossAmountLocal, contractor.currencyCode, contractor.currencySymbol) }} </span> </div> <div class="border-border bg-muted/40 rounded-lg border p-2.5"> <span class="text-muted-foreground block">Total Hours</span> <span class="text-foreground text-sm font-bold tabular-nums"> {{ contractor.hoursLogged.toFixed(1) }} hrs </span> <span class="text-success text-success block text-xs font-medium tabular-nums"> {{ contractor.regularHours.toFixed(0) }}h reg · {{ contractor.overtimeHours.toFixed(0) }}h ot </span> </div> <div class="border-border bg-muted/40 rounded-lg border p-2.5"> <span class="text-muted-foreground block">Contract Rate</span> <span class="text-foreground text-sm font-bold tabular-nums"> {{ formatUsd(contractor.hourlyRateUsd) }}/hr </span> <span class="text-muted-foreground/70 block truncate text-xs"> {{ contractor.taxCompliance.jurisdiction }} </span> </div> </div> </div> <!-- Drawer Body: Timesheet Breakdown --> <div class="flex-1 space-y-6 overflow-y-auto px-6 py-5"> <!-- Verification Callout Banner --> <div class="border-success/20 bg-success/10 text-success flex items-start gap-3 rounded-lg border p-3.5 text-xs" > <lucide-icon [img]="ShieldCheck" class="text-success mt-0.5 size-4 shrink-0" /> <div class="space-y-0.5"> <p class="text-success font-semibold"> {{ contractor.verificationBadge.label }} </p> <p class="text-success leading-normal"> {{ contractor.verificationBadge.subtext }}. Automated hash verification confirmed timestamps match developer activity during the billing window. </p> </div> </div> <!-- Daily Task Itemization --> <div class="space-y-3"> <div class="flex items-center justify-between gap-x-2"> <h2 class="text-muted-foreground text-xs font-semibold"> Daily Itemized Activity Log ({{ contractor.dailyEntries.length }} Work Days) </h2> <span class="text-muted-foreground font-mono text-xs"> {{ contractor.payPeriod }} </span> </div> <div class="space-y-2.5"> @for (entry of contractor.dailyEntries; track entry.id) { <div class="border-border bg-card hover:border-border/80 space-y-2 rounded-lg border p-3.5 transition-colors" > <div class="flex items-center justify-between gap-x-2"> <div class="flex items-center gap-2"> <span class="text-foreground text-xs font-semibold">{{ entry.day }}</span> <span ui-badge variant="outline" class="border-border text-muted-foreground h-4 py-0 text-xs"> {{ entry.date }} </span> </div> <div class="flex items-center gap-2"> <span class="text-foreground text-xs font-bold tabular-nums"> {{ entry.hours.toFixed(1) }} hrs </span> <span class="text-muted-foreground text-xs tabular-nums"> ({{ formatUsd(entry.hours * contractor.hourlyRateUsd) }}) </span> </div> </div> <p class="text-muted-foreground text-xs leading-relaxed"> {{ entry.task }} </p> <!-- Linked Deliverables (PR / Commit / Figma / CI) --> <div class="flex flex-wrap items-center gap-2 pt-1"> <div class="border-border bg-muted/50 text-foreground inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 font-mono text-xs" > @if (entry.deliverableType === 'github_pr') { <lucide-icon [img]="GitPullRequest" class="text-chart-1 size-3" /> <span>{{ entry.referenceId }}</span> <span class="text-muted-foreground">({{ entry.referenceLabel }})</span> @if (entry.commitCount) { <span class="text-muted-foreground">· {{ entry.commitCount }} commits</span> } } @else if (entry.deliverableType === 'figma') { <lucide-icon [img]="FileCode" class="text-chart-2 size-3" /> <span>{{ entry.referenceId }}</span> <span class="text-muted-foreground">({{ entry.referenceLabel }})</span> } @else { <lucide-icon [img]="CheckCircle2" class="text-success size-3" /> <span>{{ entry.referenceId }}</span> <span class="text-muted-foreground">({{ entry.referenceLabel }})</span> } </div> <span ui-badge variant="outline" class="border-success/20 bg-success/10 text-success h-5 gap-1 py-0 text-xs" > <lucide-icon [img]="Check" class="size-2.5" /> Verified </span> </div> </div> } </div> </div> <!-- Tax & Bank Payout Compliance Box --> <div class="border-border bg-muted/20 space-y-2.5 rounded-lg border p-4 text-xs"> <h3 class="text-foreground flex items-center gap-1.5 font-bold"> <lucide-icon [img]="FileCheck" class="text-primary size-3.5" /> Compliance & Disbursement Routing </h3> <div class="grid grid-cols-2 gap-3 pt-1"> <div> <span class="text-muted-foreground block">Tax Form Status:</span> <span class="text-foreground font-medium"> Form W-8BEN ({{ contractor.taxCompliance.w8benStatus.toUpperCase() }}) </span> <span class="text-muted-foreground block font-mono text-xs">{{ contractor.taxCompliance.taxIdMasked }}</span> </div> <div> <span class="text-muted-foreground block">Disbursement Route:</span> <span class="text-foreground font-medium"> {{ contractor.paymentMethod.type }} Payout </span> <span class="text-muted-foreground block text-xs">{{ contractor.paymentMethod.bankName }}</span> </div> </div> </div> </div> <!-- Drawer Footer Actions --> <div ui-sheet-footer class="border-border/80 bg-muted/20 flex flex-row items-center justify-between gap-3 border-t px-6 py-4" > <button ui-button ui-sheet-close variant="outline" size="sm" class="text-xs">Close</button> <div class="flex items-center gap-2"> @if (contractor.status !== 'disputed') { <button ui-button variant="outline" size="sm" class="border-destructive/30 text-destructive hover:bg-destructive/10 text-destructive text-xs" (click)="dispute.emit()" > <lucide-icon [img]="XCircle" class="mr-1.5 size-3.5" /> Dispute Invoice </button> } @if (contractor.status !== 'approved') { <button ui-button variant="default" size="sm" class="text-xs shadow-xs" (click)="approve.emit()"> <lucide-icon [img]="Check" class="mr-1.5 size-3.5" /> Approve ({{ formatUsd(contractor.grossAmountUsd) }}) </button> } @else { <span class="text-success flex items-center gap-1 text-xs font-semibold"> <lucide-icon [img]="CheckCircle2" class="size-4" /> Approved & Queued </span> } </div> </div> } </div> </div> `, }) export class UiTimesheetDetailSheetComponent { @Input() open = false @Input() selectedContractor?: ContractorSubmission @Input() formatUsd: (val: number) => string = (v) => String(v) @Input() formatLocal: (val: number, currencyCode: string, currencySymbol: string) => string = (v) => String(v) @Output() readonly openChange = new EventEmitter<boolean>() @Output() readonly approve = new EventEmitter<void>() @Output() readonly dispute = new EventEmitter<void>() protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly FileCheck = FileCheck protected readonly FileCode = FileCode protected readonly GitPullRequest = GitPullRequest protected readonly ShieldCheck = ShieldCheck protected readonly XCircle = XCircle }src/app/components/blocks/contractor-timesheet-approval/timesheet-kpi-cards.component.ts6 kB
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { Clock, LucideAngularModule, ShieldCheck, Timer, Users } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiCardComponent, UiCardContentComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' /** Four billing overview KPI cards: pending approval amount, total hours, scheduled payouts, active contractors. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-timesheet-kpi-cards, [ui-timesheet-kpi-cards]', standalone: true, host: { '[attr.class]': '"contents"' }, imports: [LucideAngularModule, UiCardComponent, UiCardContentComponent, UiCardHeaderComponent, UiCardTitleComponent], template: ` <div [class]="rootClass"> <!-- Card 1: Pending Approval Amount --> <div ui-card class="border-border bg-card shadow-xs transition-colors"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Pending Approval Amount</h3> <div class="border-warning/20 bg-warning/10 text-warning flex size-8 items-center justify-center rounded-lg border" > <lucide-icon [img]="Clock" class="size-4" /> </div> </div> <div ui-card-content class="space-y-1"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums"> {{ formatUsd(pendingAmountUsd) }} </div> <p class="text-muted-foreground text-xs"> <span class="text-warning text-warning font-semibold tabular-nums"> {{ pendingCount }} invoices </span> awaiting your approval </p> <div class="pt-2"> <span class="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5 text-xs tabular-nums" > £3,800 · ¥810k · R$41k </span> </div> </div> </div> <!-- Card 2: Total Contractor Hours --> <div ui-card class="border-border bg-card shadow-xs transition-colors"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Contractor Hours</h3> <div class="border-info/20 bg-info/10 text-info flex size-8 items-center justify-center rounded-lg border"> <lucide-icon [img]="Timer" class="size-4" /> </div> </div> <div ui-card-content class="space-y-1"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums"> {{ totalHoursLogged.toFixed(1) }} hrs </div> <p class="text-muted-foreground text-xs"> <span class="text-foreground font-medium tabular-nums">160.0 regular</span> · 80.0 overtime / project </p> <div class="pt-2"> <span class="bg-info/10 text-info inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium"> 100% Timesheets submitted </span> </div> </div> </div> <!-- Card 3: Approved & Scheduled Payouts --> <div ui-card class="border-border bg-card shadow-xs transition-colors"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Approved & Scheduled</h3> <div class="border-success/20 bg-success/10 text-success flex size-8 items-center justify-center rounded-lg border" > <lucide-icon [img]="ShieldCheck" class="size-4" /> </div> </div> <div ui-card-content class="space-y-1"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums"> {{ formatUsd(scheduledPayoutsTotal) }} </div> <p class="text-muted-foreground text-xs"> <span class="text-success text-success font-medium tabular-nums"> 5 invoices </span> batched for Aug 25 payout </p> <div class="pt-2"> <span class="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5 text-xs"> Direct SEPA · ACH · SWIFT </span> </div> </div> </div> <!-- Card 4: Active International Contractors --> <div ui-card class="border-border bg-card shadow-xs transition-colors"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Active Contractors</h3> <div class="border-chart-1/20 bg-chart-1/10 text-chart-1 flex size-8 items-center justify-center rounded-lg border" > <lucide-icon [img]="Users" class="size-4" /> </div> </div> <div ui-card-content class="space-y-1"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">8 Contractors</div> <p class="text-muted-foreground text-xs">Across 5 countries (UK, JP, BR, FR, CA)</p> <div class="pt-2"> <span class="bg-chart-1/10 text-chart-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" > 100% W-8BEN Tax Valid </span> </div> </div> </div> </div> `, }) export class UiTimesheetKpiCardsComponent { @Input() pendingAmountUsd = 0 @Input() pendingCount = 0 @Input() totalHoursLogged = 0 @Input() scheduledPayoutsTotal = 0 @Input() formatUsd: (val: number) => string = (v) => String(v) @Input('class') className?: string get rootClass(): string { return cn('grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4', this.className) } protected readonly Clock = Clock protected readonly ShieldCheck = ShieldCheck protected readonly Timer = Timer protected readonly Users = Users }src/app/components/blocks/contractor-timesheet-approval/timesheet-table-row.component.ts11.4 kB
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core' import { AlertCircle, Check, CheckCircle2, Download, Eye, FileText, LucideAngularModule, MoreHorizontal, ShieldCheck, XCircle, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, } from '@/ui/dropdown-menu/dropdown-menu.component' import { UiTableCellComponent } from '@/ui/table/table.component' import type { ContractorSubmission } from './timesheet-types' /** * One contractor invoice row. Attribute selector (`tr[ui-timesheet-table-row]`) so the host * IS the table row — a wrapper element inside <tbody> would break table layout. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'tr[ui-timesheet-table-row]', standalone: true, // Same host contract as UiTableRow (data-slot + base row classes) merged with the // consumer `class`, so the row needs no wrapper element inside <tbody>. host: { '[attr.data-slot]': '"table-row"', '[attr.data-uipkge]': '""', '[class]': 'hostClass' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, UiTableCellComponent, ], template: ` <!-- Contractor & Location --> <td ui-table-cell class="py-4"> <div class="flex items-center gap-3"> <ui-avatar class="border-border size-10 shrink-0 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold"> {{ contractor.initials }} </ui-avatar-fallback> </ui-avatar> <div class="space-y-0.5"> <div class="flex items-center gap-1.5"> <button type="button" class="text-foreground focus-visible:ring-ring cursor-pointer rounded-xs text-left text-sm font-semibold hover:underline focus-visible:ring-2 focus-visible:outline-none" (click)="openDrawer.emit(contractor)" > {{ contractor.name }} </button> <span class="text-sm" [attr.title]="contractor.country">{{ contractor.flag }}</span> </div> <p class="text-muted-foreground flex items-center gap-1 text-xs"> <span>{{ contractor.city }}</span> <span>•</span> <span class="text-muted-foreground/80 font-mono">{{ contractor.invoiceNumber }}</span> </p> </div> </div> </td> <!-- Role & Rate --> <td ui-table-cell class="py-4"> <div class="space-y-0.5"> <p class="text-foreground text-xs font-medium">{{ contractor.role }}</p> <p class="text-muted-foreground text-xs tabular-nums"> {{ formatUsd(contractor.hourlyRateUsd) }}/hr <span class="text-muted-foreground/70"> ({{ formatLocal(contractor.hourlyRateLocal, contractor.currencyCode, contractor.currencySymbol) }}/hr) </span> </p> </div> </td> <!-- Hours Logged with daily breakdown expander button --> <td ui-table-cell class="py-4"> <div class="space-y-1"> <div class="flex items-center gap-1.5"> <span class="text-foreground text-sm font-bold tabular-nums"> {{ contractor.hoursLogged.toFixed(1) }} hrs </span> </div> <div class="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"> <span class="text-success font-medium"> {{ contractor.regularHours.toFixed(0) }}h reg </span> @if (contractor.overtimeHours > 0) { <span>+</span> <span class="text-info font-medium"> {{ contractor.overtimeHours.toFixed(0) }}h ot </span> } </div> </div> </td> <!-- Invoice Gross (Local & USD) --> <td ui-table-cell class="py-4"> <div class="space-y-1"> @if (currencyDisplay === 'both') { <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-sm font-bold tabular-nums"> {{ formatUsd(contractor.grossAmountUsd) }} </span> <span class="text-muted-foreground text-xs font-normal">USD</span> </div> <p class="text-muted-foreground text-xs tabular-nums"> {{ formatLocal(contractor.grossAmountLocal, contractor.currencyCode, contractor.currencySymbol) }} <span class="text-muted-foreground/60 text-xs">(FX: {{ contractor.fxRate.toFixed(4) }})</span> </p> } @else if (currencyDisplay === 'usd') { <div class="text-foreground text-sm font-bold tabular-nums"> {{ formatUsd(contractor.grossAmountUsd) }} USD </div> <p class="text-muted-foreground text-xs tabular-nums">Rate: {{ formatUsd(contractor.hourlyRateUsd) }}/hr</p> } @else { <div class="text-foreground text-sm font-bold tabular-nums"> {{ formatLocal(contractor.grossAmountLocal, contractor.currencyCode, contractor.currencySymbol) }} </div> <p class="text-muted-foreground text-xs tabular-nums">Conv: {{ formatUsd(contractor.grossAmountUsd) }} USD</p> } </div> </td> <!-- Timesheet Verification Status --> <td ui-table-cell class="py-4"> <div class="space-y-1"> <span ui-badge variant="outline" class="border-success/25 bg-success/10 text-success gap-1 text-xs font-medium"> <lucide-icon [img]="CheckCircle2" class="text-success size-3" /> {{ contractor.verificationBadge.label }} </span> <p class="text-muted-foreground max-w-[200px] truncate text-xs" [attr.title]="contractor.verificationBadge.subtext" > {{ contractor.verificationBadge.subtext }} </p> </div> </td> <!-- Approval Status --> <td ui-table-cell class="py-4"> @if (contractor.status === 'pending') { <span ui-badge 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 rounded-full"></span> Pending Review </span> } @else if (contractor.status === 'approved') { <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium"> <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> Approved </span> } @else { <span ui-badge variant="outline" class="border-destructive/30 bg-destructive/10 text-destructive text-xs font-medium" > <lucide-icon [img]="XCircle" class="text-destructive mr-1 size-3" /> Disputed </span> } </td> <!-- Action Buttons --> <td ui-table-cell class="py-4 text-right"> <div class="flex items-center justify-end gap-1.5"> <button ui-button variant="outline" size="sm" class="border-border hover:bg-muted h-8 gap-1 px-2.5 text-xs font-medium" (click)="openDrawer.emit(contractor)" > <lucide-icon [img]="Eye" class="text-muted-foreground size-3.5" /> Timesheet </button> @if (contractor.status === 'pending') { <button ui-button variant="default" size="sm" class="h-8 px-2.5 text-xs font-medium shadow-xs" (click)="approveInvoice.emit(contractor.id)" > <lucide-icon [img]="Check" class="mr-1 size-3.5" /> Approve </button> } <!-- Dropdown Menu for Secondary Actions --> <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" > <lucide-icon [img]="MoreHorizontal" class="size-4" /> <span class="sr-only">Open contractor menu</span> </button> <ui-dropdown-menu-content align="end" class="w-48"> <div ui-dropdown-menu-label class="text-muted-foreground text-xs"> Invoice {{ contractor.invoiceNumber }} </div> <ui-dropdown-menu-item class="cursor-pointer text-xs" (select)="openDrawer.emit(contractor)"> <lucide-icon [img]="FileText" class="text-muted-foreground mr-2 size-3.5" /> Itemized Timesheet </ui-dropdown-menu-item> @if (contractor.status !== 'approved') { <ui-dropdown-menu-item class="text-success cursor-pointer text-xs" (select)="approveInvoice.emit(contractor.id)" > <lucide-icon [img]="Check" class="mr-2 size-3.5" /> Approve Invoice </ui-dropdown-menu-item> } @if (contractor.status !== 'disputed') { <ui-dropdown-menu-item class="text-destructive cursor-pointer text-xs" (select)="disputeInvoice.emit(contractor.id)" > <lucide-icon [img]="AlertCircle" class="mr-2 size-3.5" /> Dispute / Reject </ui-dropdown-menu-item> } <ui-dropdown-menu-separator /> <ui-dropdown-menu-item class="cursor-pointer text-xs"> <lucide-icon [img]="Download" class="text-muted-foreground mr-2 size-3.5" /> Download Invoice PDF </ui-dropdown-menu-item> <ui-dropdown-menu-item class="cursor-pointer text-xs"> <lucide-icon [img]="ShieldCheck" class="text-muted-foreground mr-2 size-3.5" /> View W-8BEN Form </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </div> </td> `, }) export class UiTimesheetTableRowComponent { @Input({ required: true }) contractor!: ContractorSubmission @Input() currencyDisplay: 'both' | 'usd' | 'local' = 'both' @Input() formatUsd: (val: number) => string = (v) => String(v) @Input() formatLocal: (val: number, currencyCode: string, currencySymbol: string) => string = (v) => String(v) @Input('class') className?: string @Output() readonly openDrawer = new EventEmitter<ContractorSubmission>() @Output() readonly approveInvoice = new EventEmitter<string>() @Output() readonly disputeInvoice = new EventEmitter<string>() get hostClass(): string { return cn( 'table-row hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors duration-150', this.className, ) } protected readonly AlertCircle = AlertCircle protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Download = Download protected readonly Eye = Eye protected readonly FileText = FileText protected readonly MoreHorizontal = MoreHorizontal protected readonly ShieldCheck = ShieldCheck protected readonly XCircle = XCircle }src/app/components/blocks/contractor-timesheet-approval/timesheet-types.ts1.3 kB
export type ApprovalStatus = 'pending' | 'approved' | 'disputed' export type DeliverableType = 'github_pr' | 'figma' | 'ci_pipeline' | 'code_review' export 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 } export 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[] }src/app/components/blocks/contractor-timesheet-approval/timesheet-data.ts13.1 kB
import type { ContractorSubmission } from './timesheet-types' export const initialSubmissions: ContractorSubmission[] = [ { id: 'ctr-1', name: "Liam O'Connor", initials: 'LO', role: 'Senior React Engineer', country: 'United Kingdom', countryCode: 'GB', flag: '🇬🇧', city: 'London, UK', contractType: 'Hourly Contract · $97.00/hr', hourlyRateUsd: 97.0, hourlyRateLocal: 76.0, currencyCode: 'GBP', currencySymbol: '£', fxRate: 1.2763, hoursLogged: 50.0, regularHours: 40.0, overtimeHours: 10.0, grossAmountLocal: 3800.0, grossAmountUsd: 4850.0, verificationBadge: { label: 'Verified with GitHub PRs', subtext: '14 PRs merged · 46 verified commits', status: 'verified', }, status: 'pending', invoiceNumber: 'INV-2026-UK-0841', submittedAt: 'Aug 21, 2026 · 17:45 UTC', payPeriod: 'Aug 08 – Aug 21, 2026', taxCompliance: { w8benStatus: 'valid', taxIdMasked: 'GB-UTR ••••••8912', jurisdiction: 'HMRC UK (Non-US Source)', }, paymentMethod: { type: 'SWIFT', accountMasked: 'Barclays •••• 4421', bankName: 'Barclays Bank UK PLC', }, dailyEntries: [ { id: 'entry-1-1', date: '2026-08-17', day: 'Mon, Aug 17', hours: 10.0, regularHours: 8.0, overtimeHours: 2.0, task: 'Refactoring OKLCH Color Engine & AST Parser for sub-theme resolution', deliverableType: 'github_pr', referenceId: 'PR #412', referenceLabel: 'feature/v4-ast-parser', commitCount: 12, verified: true, }, { id: 'entry-1-2', date: '2026-08-18', day: 'Tue, Aug 18', hours: 10.0, regularHours: 8.0, overtimeHours: 2.0, task: 'SSR Hydration Mismatch Resolver for cross-framework Island Architectures', deliverableType: 'github_pr', referenceId: 'PR #415', referenceLabel: 'fix/island-hydration', commitCount: 8, verified: true, }, { id: 'entry-1-3', date: '2026-08-19', day: 'Wed, Aug 19', hours: 10.0, regularHours: 8.0, overtimeHours: 2.0, task: 'Design token runtime compilation and memoized theme class cache', deliverableType: 'github_pr', referenceId: 'PR #418', referenceLabel: 'perf/token-runtime', commitCount: 7, verified: true, }, { id: 'entry-1-4', date: '2026-08-20', day: 'Thu, Aug 20', hours: 10.0, regularHours: 8.0, overtimeHours: 2.0, task: 'Figma token sync GitHub Action workflow optimization and schema tests', deliverableType: 'github_pr', referenceId: 'PR #419', referenceLabel: 'ci/figma-token-sync', commitCount: 9, verified: true, }, { id: 'entry-1-5', date: '2026-08-21', day: 'Fri, Aug 21', hours: 10.0, regularHours: 8.0, overtimeHours: 2.0, task: 'Reka UI combobox accessible keyboard navigation bridge and focus ring parity', deliverableType: 'github_pr', referenceId: 'PR #423', referenceLabel: 'feat/reka-combobox-a11y', commitCount: 10, verified: true, }, ], }, { id: 'ctr-2', name: 'Yuki Tanaka', initials: 'YT', role: 'UI/UX Designer', country: 'Japan', countryCode: 'JP', flag: '🇯🇵', city: 'Tokyo, JP', contractType: 'Hourly Contract · $90.00/hr', hourlyRateUsd: 90.0, hourlyRateLocal: 13500, currencyCode: 'JPY', currencySymbol: '¥', fxRate: 0.00667, hoursLogged: 60.0, regularHours: 40.0, overtimeHours: 20.0, grossAmountLocal: 810000, grossAmountUsd: 5400.0, verificationBadge: { label: 'Figma Files & Handoff Verified', subtext: '8 wireframes & design spec approved', status: 'verified', }, status: 'pending', invoiceNumber: 'INV-2026-JP-0219', submittedAt: 'Aug 21, 2026 · 14:10 UTC', payPeriod: 'Aug 08 – Aug 21, 2026', taxCompliance: { w8benStatus: 'valid', taxIdMasked: 'JP-NTA ••••••4401', jurisdiction: 'National Tax Agency Japan', }, paymentMethod: { type: 'Local Wire', accountMasked: 'MUFG •••• 9102', bankName: 'Mitsubishi UFJ Financial Group', }, dailyEntries: [ { id: 'entry-2-1', date: '2026-08-17', day: 'Mon, Aug 17', hours: 12.0, regularHours: 8.0, overtimeHours: 4.0, task: 'Enterprise Multi-currency Invoicing Flow User Journey Map & Edge Cases', deliverableType: 'figma', referenceId: 'Node #492:102', referenceLabel: 'figma.com/@uipkge/multi-currency-flow', commitCount: 6, verified: true, }, { id: 'entry-2-2', date: '2026-08-18', day: 'Tue, Aug 18', hours: 12.0, regularHours: 8.0, overtimeHours: 4.0, task: 'Contractor Compliance Document Verification UX Specs & Empty States', deliverableType: 'figma', referenceId: 'Node #495:88', referenceLabel: 'figma.com/@uipkge/compliance-docs', commitCount: 5, verified: true, }, { id: 'entry-2-3', date: '2026-08-19', day: 'Wed, Aug 19', hours: 12.0, regularHours: 8.0, overtimeHours: 4.0, task: 'Mobile responsive Timesheet approval breakpoints, drawers, and gesture specs', deliverableType: 'figma', referenceId: 'Node #501:14', referenceLabel: 'figma.com/@uipkge/mobile-timesheet', commitCount: 8, verified: true, }, { id: 'entry-2-4', date: '2026-08-20', day: 'Thu, Aug 20', hours: 12.0, regularHours: 8.0, overtimeHours: 4.0, task: 'Interactive Design System Token Library for Dark/Light Themes & High-Contrast', deliverableType: 'figma', referenceId: 'Node #510:33', referenceLabel: 'figma.com/@uipkge/tokens-library', commitCount: 11, verified: true, }, { id: 'entry-2-5', date: '2026-08-21', day: 'Fri, Aug 21', hours: 12.0, regularHours: 8.0, overtimeHours: 4.0, task: 'Usability testing session synthesis, prototype refinements, and engineering handoff', deliverableType: 'figma', referenceId: 'Node #514:02', referenceLabel: 'figma.com/@uipkge/handoff-v2', commitCount: 4, verified: true, }, ], }, { id: 'ctr-3', name: 'Mateo Silva', initials: 'MS', role: 'Go Backend Specialist', country: 'Brazil', countryCode: 'BR', flag: '🇧🇷', city: 'São Paulo, BR', contractType: 'Hourly Contract · $91.11/hr', hourlyRateUsd: 91.11, hourlyRateLocal: 455.55, currencyCode: 'BRL', currencySymbol: 'R$', fxRate: 0.2, hoursLogged: 90.0, regularHours: 40.0, overtimeHours: 50.0, grossAmountLocal: 41000.0, grossAmountUsd: 8200.0, verificationBadge: { label: 'Verified with GitHub PRs', subtext: 'Kafka partitioning & Redis idempotency engine', status: 'verified', }, status: 'pending', invoiceNumber: 'INV-2026-BR-0572', submittedAt: 'Aug 21, 2026 · 19:20 UTC', payPeriod: 'Aug 08 – Aug 21, 2026', taxCompliance: { w8benStatus: 'valid', taxIdMasked: 'BR-CNPJ ••••••7100', jurisdiction: 'Receita Federal do Brasil', }, paymentMethod: { type: 'SWIFT', accountMasked: 'Itaú Unibanco •••• 8831', bankName: 'Banco Itaú S.A.', }, dailyEntries: [ { id: 'entry-3-1', date: '2026-08-17', day: 'Mon, Aug 17', hours: 18.0, regularHours: 8.0, overtimeHours: 10.0, task: 'Distributed transaction saga orchestrator for multi-currency payout reconciliation', deliverableType: 'github_pr', referenceId: 'PR #289', referenceLabel: 'feat/saga-orchestrator', commitCount: 15, verified: true, }, { id: 'entry-3-2', date: '2026-08-18', day: 'Tue, Aug 18', hours: 18.0, regularHours: 8.0, overtimeHours: 10.0, task: 'Zero-downtime PostgreSQL table partitioning for timesheet audit logs & retention', deliverableType: 'github_pr', referenceId: 'PR #294', referenceLabel: 'db/pg-partitioning', commitCount: 7, verified: true, }, { id: 'entry-3-3', date: '2026-08-19', day: 'Wed, Aug 19', hours: 18.0, regularHours: 8.0, overtimeHours: 10.0, task: 'Webhook delivery retry backoff with Kafka dead-letter queue & idempotency keys', deliverableType: 'github_pr', referenceId: 'PR #298', referenceLabel: 'feat/kafka-dlq-retries', commitCount: 19, verified: true, }, { id: 'entry-3-4', date: '2026-08-20', day: 'Thu, Aug 20', hours: 18.0, regularHours: 8.0, overtimeHours: 10.0, task: 'Prometheus latency metric instrumentation & Grafana alert rules for payment workers', deliverableType: 'github_pr', referenceId: 'PR #302', referenceLabel: 'obs/prometheus-metrics', commitCount: 5, verified: true, }, { id: 'entry-3-5', date: '2026-08-21', day: 'Fri, Aug 21', hours: 18.0, regularHours: 8.0, overtimeHours: 10.0, task: 'Payment gateway failover simulation & multi-region load testing at 25k req/s', deliverableType: 'github_pr', referenceId: 'PR #307', referenceLabel: 'test/gateway-failover', commitCount: 12, verified: true, }, ], }, { id: 'ctr-4', name: 'Chloe Dubois', initials: 'CD', role: 'QA Automation Lead', country: 'France', countryCode: 'FR', flag: '🇫🇷', city: 'Paris, FR', contractType: 'Hourly Contract · $92.00/hr', hourlyRateUsd: 92.0, hourlyRateLocal: 85.0, currencyCode: 'EUR', currencySymbol: '€', fxRate: 1.0824, hoursLogged: 40.0, regularHours: 40.0, overtimeHours: 0.0, grossAmountLocal: 3400.0, grossAmountUsd: 3680.0, verificationBadge: { label: 'Playwright E2E Suites 100% Pass', subtext: 'CI Run #8821 verified · 342 browser tests green', status: 'verified', }, status: 'approved', invoiceNumber: 'INV-2026-FR-1108', submittedAt: 'Aug 20, 2026 · 16:30 UTC', payPeriod: 'Aug 08 – Aug 21, 2026', taxCompliance: { w8benStatus: 'valid', taxIdMasked: 'FR-SIRET ••••••3921', jurisdiction: 'DGFiP France (EU VAT Reg)', }, paymentMethod: { type: 'SEPA', accountMasked: 'BNP Paribas •••• 5590', bankName: 'BNP Paribas S.A.', }, dailyEntries: [ { id: 'entry-4-1', date: '2026-08-17', day: 'Mon, Aug 17', hours: 8.0, regularHours: 8.0, overtimeHours: 0.0, task: 'Playwright end-to-end multi-currency payment checkout regression suite', deliverableType: 'ci_pipeline', referenceId: 'CI Run #8812', referenceLabel: 'e2e-checkout-matrix', commitCount: 8, verified: true, }, { id: 'entry-4-2', date: '2026-08-18', day: 'Tue, Aug 18', hours: 8.0, regularHours: 8.0, overtimeHours: 0.0, task: 'Visual regression tests for dark mode and high-contrast accessibility across viewports', deliverableType: 'ci_pipeline', referenceId: 'CI Run #8815', referenceLabel: 'visual-regression-a11y', commitCount: 6, verified: true, }, { id: 'entry-4-3', date: '2026-08-19', day: 'Wed, Aug 19', hours: 8.0, regularHours: 8.0, overtimeHours: 0.0, task: 'Automated timesheet submission stress testing & concurrent approval race conditions', deliverableType: 'ci_pipeline', referenceId: 'CI Run #8818', referenceLabel: 'load-timesheet-concurrency', commitCount: 14, verified: true, }, { id: 'entry-4-4', date: '2026-08-20', day: 'Thu, Aug 20', hours: 8.0, regularHours: 8.0, overtimeHours: 0.0, task: 'Cross-browser matrix testing (Chromium, Firefox, WebKit) on macOS/Linux runners', deliverableType: 'ci_pipeline', referenceId: 'CI Run #8820', referenceLabel: 'cross-browser-ci', commitCount: 4, verified: true, }, { id: 'entry-4-5', date: '2026-08-21', day: 'Fri, Aug 21', hours: 8.0, regularHours: 8.0, overtimeHours: 0.0, task: 'CI test sharding optimization reducing full regression duration from 18m to 3m 12s', deliverableType: 'ci_pipeline', referenceId: 'CI Run #8821', referenceLabel: 'ci-sharding-speedup', commitCount: 5, verified: true, }, ], }, ]src/app/components/blocks/contractor-timesheet-approval/index.ts0.5 kB
export { UiContractorTimesheetApprovalComponent, type ApprovalStatus, type ContractorSubmission, type CurrencyDisplay, type DailyTimesheetEntry, type DeliverableType, type TimesheetStatusFilter, } from './contractor-timesheet-approval.component' export { UiTimesheetDetailSheetComponent } from './timesheet-detail-sheet.component' export { UiTimesheetKpiCardsComponent } from './timesheet-kpi-cards.component' export { UiTimesheetTableRowComponent } from './timesheet-table-row.component' export { initialSubmissions } from './timesheet-data'
Raw manifest:https://uipkge.dev/r/angular/contractor-timesheet-approval.json