Time Off Portal
blockhrEmployee leave management & PTO portal: 4 PTO balance summary cards with progress meters, interactive visual month calendar with approved leave and holiday indicators, leave request dialog and inline form with working day calculations, manager pending approval cards with instant action buttons, upcoming team out-of-office tracker, and company holiday list.
Installation
$pnpm dlx uipkge-ng@latest add time-off-portal$npx uipkge-ng@latest add time-off-portal$yarn dlx uipkge-ng@latest add time-off-portal$bunx uipkge-ng@latest add time-off-portalManifest:
https://uipkge.dev/r/angular/time-off-portal.jsonInstalls to:src/app/components/blocks/time-off-portal/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.
PendingRequestinterface PendingRequest {
id: string
name: string
role: string
avatar: string
dateRange: string
duration: string
type: string
typeBadgeClass: string
notes: string
status: 'pending' | 'approved' | 'declined'
}TeamMemberOOOinterface TeamMemberOOO {
id: string
name: string
role: string
avatar: string
type: string
typeClass: string
dates: string
returnNote: string
}CompanyHolidayinterface CompanyHoliday {
id: string
name: string
date: string
duration: string
isLongWeekend?: boolean
}CalendarCellinterface CalendarCell {
dateKey: string
dayNumber: number
inMonth: boolean
eventType: 'approved' | 'holiday' | 'pending' | 'team' | null
label: string
}PtoBalanceinterface PtoBalance {
id: string
name: string
available: number
total: number
unit: string
progress: number
note: string
subtext: string
icon: typeof Palmtree
colorClass: string
}Files installed (2)
src/app/components/blocks/time-off-portal/time-off-portal.component.ts35.5 kB
// Employee leave management & PTO portal: PTO balance summary cards with progress meters, // interactive visual month calendar with approved leave and holiday indicators, leave request // dialog and inline form with working day calculations, manager pending approval cards, // upcoming team out-of-office tracker, and company holiday list. Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { CalendarDays, Check, CheckCircle2, ChevronLeft, ChevronRight, Clock, HeartPulse, Info, LucideAngularModule, Palmtree, Plus, Send, Smile, UserCheck, Users, X, } 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 { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface PendingRequest { id: string name: string role: string avatar: string dateRange: string duration: string type: string typeBadgeClass: string notes: string status: 'pending' | 'approved' | 'declined' } export interface TeamMemberOOO { id: string name: string role: string avatar: string type: string typeClass: string dates: string returnNote: string } export interface CompanyHoliday { id: string name: string date: string duration: string isLongWeekend?: boolean } export interface CalendarCell { dateKey: string dayNumber: number inMonth: boolean eventType: 'approved' | 'holiday' | 'pending' | 'team' | null label: string } interface PtoBalance { id: string name: string available: number total: number unit: string progress: number note: string subtext: string icon: typeof Palmtree colorClass: string } const ptoBalances: PtoBalance[] = [ { id: 'pto', name: 'Paid Time Off (PTO)', available: 18, total: 25, unit: 'days', progress: 72, note: '+2.08 days accrued monthly', subtext: '18 of 25 days annual', icon: Palmtree, colorClass: 'text-success bg-success/10', }, { id: 'sick', name: 'Sick Leave', available: 8, total: 10, unit: 'days', progress: 80, note: 'Resets Jan 1, 2027', subtext: '8 of 10 days annual', icon: HeartPulse, colorClass: 'text-destructive bg-destructive/10', }, { id: 'personal', name: 'Personal / Wellness Days', available: 3, total: 4, unit: 'days', progress: 75, note: '1 day used this year', subtext: '3 of 4 days annual', icon: Smile, colorClass: 'text-warning bg-warning/10', }, { id: 'volunteer', name: 'Volunteer & Compassionate', available: 2, total: 2, unit: 'days', progress: 100, note: 'Fully available', subtext: '2 days annual allowance', icon: UserCheck, colorClass: 'text-chart-1 bg-chart-1/10', }, ] export const calendarDays: CalendarCell[] = [ { dateKey: '2026-08-31', dayNumber: 31, inMonth: false, eventType: null, label: '' }, { dateKey: '2026-09-01', dayNumber: 1, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-02', dayNumber: 2, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-03', dayNumber: 3, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-04', dayNumber: 4, inMonth: true, eventType: 'team', label: 'Team OOO' }, { dateKey: '2026-09-05', dayNumber: 5, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-06', dayNumber: 6, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-07', dayNumber: 7, inMonth: true, eventType: 'holiday', label: 'Labor Day' }, { dateKey: '2026-09-08', dayNumber: 8, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-09', dayNumber: 9, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-10', dayNumber: 10, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-11', dayNumber: 11, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-12', dayNumber: 12, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-13', dayNumber: 13, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-14', dayNumber: 14, inMonth: true, eventType: 'approved', label: 'Vacation' }, { dateKey: '2026-09-15', dayNumber: 15, inMonth: true, eventType: 'approved', label: 'Vacation' }, { dateKey: '2026-09-16', dayNumber: 16, inMonth: true, eventType: 'approved', label: 'Vacation' }, { dateKey: '2026-09-17', dayNumber: 17, inMonth: true, eventType: 'approved', label: 'Vacation' }, { dateKey: '2026-09-18', dayNumber: 18, inMonth: true, eventType: 'approved', label: 'Vacation' }, { dateKey: '2026-09-19', dayNumber: 19, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-20', dayNumber: 20, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-21', dayNumber: 21, inMonth: true, eventType: 'pending', label: 'Pending' }, { dateKey: '2026-09-22', dayNumber: 22, inMonth: true, eventType: 'pending', label: 'Pending' }, { dateKey: '2026-09-23', dayNumber: 23, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-24', dayNumber: 24, inMonth: true, eventType: 'team', label: 'Team OOO' }, { dateKey: '2026-09-25', dayNumber: 25, inMonth: true, eventType: 'team', label: 'Team OOO' }, { dateKey: '2026-09-26', dayNumber: 26, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-27', dayNumber: 27, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-28', dayNumber: 28, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-29', dayNumber: 29, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-09-30', dayNumber: 30, inMonth: true, eventType: null, label: '' }, { dateKey: '2026-10-01', dayNumber: 1, inMonth: false, eventType: null, label: '' }, { dateKey: '2026-10-02', dayNumber: 2, inMonth: false, eventType: null, label: '' }, { dateKey: '2026-10-03', dayNumber: 3, inMonth: false, eventType: null, label: '' }, { dateKey: '2026-10-04', dayNumber: 4, inMonth: false, eventType: null, label: '' }, ] const defaultPendingRequests: PendingRequest[] = [ { id: 'req-1', name: 'Sarah Chen', role: 'Senior Product Designer', avatar: 'SC', dateRange: 'Sep 14 – Sep 18, 2026', duration: '5 working days', type: 'Vacation', typeBadgeClass: 'bg-success/10 text-success border-success/20', notes: 'Annual family camping trip. Design handoff completed in Figma.', status: 'pending', }, { id: 'req-2', name: 'Marcus Vance', role: 'Backend Engineer', avatar: 'MV', dateRange: 'Sep 22, 2026', duration: '1 day', type: 'Personal Day', typeBadgeClass: 'bg-warning/10 text-warning border-warning/20', notes: 'Apartment move and utilities setup. Reached out to on-call secondary.', status: 'pending', }, ] export const teamMembersOOO: TeamMemberOOO[] = [ { id: 'tm-1', name: 'Elena Rostova', role: 'Frontend Lead', avatar: 'ER', type: 'Vacation', typeClass: 'bg-success/10 text-success', dates: 'Sep 1 – Sep 8', returnNote: 'Returns Sep 9', }, { id: 'tm-2', name: 'David Kim', role: 'DevOps Engineer', avatar: 'DK', type: 'Sick Leave', typeClass: 'bg-destructive/10 text-destructive', dates: 'Sep 4 – Sep 5', returnNote: 'Returns Sep 8', }, { id: 'tm-3', name: 'Aisha Patel', role: 'Engineering Manager', avatar: 'AP', type: 'Personal Day', typeClass: 'bg-warning/10 text-warning', dates: 'Sep 11', returnNote: 'Returns Sep 12', }, { id: 'tm-4', name: 'Lucas Meyer', role: 'QA Analyst', avatar: 'LM', type: 'Volunteer', typeClass: 'bg-chart-1/10 text-chart-1', dates: 'Sep 18', returnNote: 'Returns Sep 21', }, ] export const companyHolidays: CompanyHoliday[] = [ { id: 'h-1', name: 'Labor Day', date: 'Mon, Sep 7, 2026', duration: 'Office Closed', isLongWeekend: true }, { id: 'h-2', name: 'Thanksgiving Break', date: 'Thu, Nov 26 – Fri, Nov 27, 2026', duration: '2 Days', isLongWeekend: true, }, { id: 'h-3', name: 'Winter Holidays', date: 'Thu, Dec 24 – Fri, Dec 25, 2026', duration: '2 Days', isLongWeekend: true, }, { id: 'h-4', name: "New Year's Day", date: 'Fri, Jan 1, 2027', duration: 'Office Closed', isLongWeekend: true }, ] export function countWorkingDays(startIso: string, endIso: string): number { if (!startIso || !endIso) return 0 const start = new Date(startIso) const end = new Date(endIso) if (isNaN(start.getTime()) || isNaN(end.getTime()) || start > end) return 0 let count = 0 const cur = new Date(start) while (cur <= end) { const day = cur.getDay() if (day !== 0 && day !== 6) count++ cur.setDate(cur.getDate() + 1) } return count } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-time-off-portal, [ui-time-off-portal]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiProgressComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiTextareaComponent, ], template: ` <div data-slot="time-off-portal" [class]="rootClass"> <!-- Header --> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <div class="flex items-center gap-2"> <h2 class="text-2xl font-bold tracking-tight">{{ title() }}</h2> <span ui-badge variant="outline" class="hidden sm:inline-flex">HR Portal</span> </div> <p class="text-muted-foreground mt-1 text-sm">{{ subtitle() }}</p> </div> <!-- Request Time Off Dialog --> <button ui-button class="gap-2 shadow-xs" (click)="isDialogOpen.set(true)"> <lucide-icon [img]="Plus" class="size-4 shrink-0" /> Request Time Off </button> </div> <ui-dialog [open]="isDialogOpen()" (openChange)="isDialogOpen.set($event)"> <ui-dialog-content class="sm:max-w-lg"> <div ui-dialog-header> <h2 ui-dialog-title>Request Time Off</h2> <p ui-dialog-description>Submit your leave request for manager approval.</p> </div> <div class="space-y-4 py-2"> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Leave Type</label> <ui-select [value]="formLeaveType()" (valueChange)="formLeaveType.set($event)"> <button ui-select-trigger> <ui-select-value placeholder="Select leave category" /> </button> <ui-select-content> <ui-select-item value="vacation">Paid Time Off (PTO) — 18 days available</ui-select-item> <ui-select-item value="sick">Sick Leave — 8 days available</ui-select-item> <ui-select-item value="personal">Personal / Wellness Day — 3 days available</ui-select-item> <ui-select-item value="volunteer">Volunteer & Compassionate — 2 days available</ui-select-item> </ui-select-content> </ui-select> </div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Start Date</label> <ui-input [value]="formStartDate()" (valueChange)="formStartDate.set($event)" type="date" /> </div> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">End Date</label> <ui-input [value]="formEndDate()" (valueChange)="formEndDate.set($event)" type="date" /> </div> </div> <div class="bg-muted/50 rounded-lg border p-3"> <div class="flex items-center justify-between text-sm"> <span class="text-muted-foreground">Total Working Days</span> <span class="font-semibold tabular-nums" >{{ calculatedDays() }} working days ({{ calculatedDays() * 8 }} hrs)</span > </div> <div class="text-muted-foreground mt-1.5 flex items-center justify-between text-xs"> <span>Remaining balance preview</span> <span class="font-medium tabular-nums">{{ remainingAfterRequest() }} days remaining</span> </div> </div> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Notes & Coverage Plan</label> <ui-textarea [value]="formNotes()" (valueChange)="formNotes.set($event)" placeholder="Mention project coverage or emergency contact details..." class="min-h-[5rem]" /> </div> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button variant="outline" (click)="isDialogOpen.set(false)">Cancel</button> <button ui-button class="gap-1.5" (click)="handleSubmitRequest()"> <lucide-icon [img]="Send" class="size-3.5 shrink-0" /> Submit Request </button> </div> </ui-dialog-content> </ui-dialog> <!-- 4 PTO Balance Summary Cards --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> @for (balance of balances; track balance.id) { <div ui-card class="shadow-xs"> <div ui-card-content class="space-y-3 p-5"> <div class="flex items-center justify-between"> <p class="text-muted-foreground text-sm font-medium">{{ balance.name }}</p> <div [class]="balanceIconClass(balance.colorClass)"> <lucide-icon [img]="balance.icon" class="size-4 shrink-0" /> </div> </div> <div> <div class="flex items-baseline gap-1.5"> <span class="text-2xl font-bold tracking-tight tabular-nums">{{ balance.available }}</span> <span class="text-muted-foreground text-xs">days available</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">{{ balance.subtext }}</p> </div> <ui-progress [value]="balance.progress" class="h-1.5" /> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <lucide-icon [img]="Info" class="size-3 shrink-0" /> <span class="truncate">{{ balance.note }}</span> </div> </div> </div> } </div> <!-- 2-Column Main Layout --> <div class="grid grid-cols-1 gap-6 lg:grid-cols-12"> <!-- Left Panel: Month Calendar View & Leave Request Form --> <div class="space-y-6 lg:col-span-7"> <!-- Month Calendar View --> <div ui-card class="shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex flex-wrap items-center justify-between gap-2"> <div> <h3 ui-card-title class="text-base font-semibold">Leave & Out-of-Office Calendar</h3> <p ui-card-description class="text-xs"> Visual calendar of team availability, approved PTO, and public holidays. </p> </div> <div class="flex items-center gap-1"> <button ui-button variant="outline" size="icon-sm" class="size-7 shrink-0"> <lucide-icon [img]="ChevronLeft" class="size-3.5 shrink-0" /> <span class="sr-only">Previous month</span> </button> <span class="px-2 text-xs font-semibold tabular-nums">{{ currentMonth() }}</span> <button ui-button variant="outline" size="icon-sm" class="size-7 shrink-0"> <lucide-icon [img]="ChevronRight" class="size-3.5 shrink-0" /> <span class="sr-only">Next month</span> </button> </div> </div> </div> <div ui-card-content class="space-y-4"> <!-- Weekday Row --> <div class="text-muted-foreground grid grid-cols-7 text-center text-xs font-medium tracking-wider uppercase" > <div>Mon</div> <div>Tue</div> <div>Wed</div> <div>Thu</div> <div>Fri</div> <div>Sat</div> <div>Sun</div> </div> <!-- Calendar Grid --> <div class="grid grid-cols-7 gap-1"> @for (cell of days; track cell.dateKey) { <button type="button" [class]="cellClass(cell)" (click)="selectedDate.set(cell.dateKey)"> <div class="flex items-center justify-between"> <span [class]="dayNumberClass(cell)"> {{ cell.dayNumber }} </span> @if (cell.eventType) { <span [class]="dotClass(cell.eventType)"></span> } </div> @if (cell.label) { <div class="mt-1 truncate"> <span [class]="labelClass(cell.eventType)"> {{ cell.label }} </span> </div> } </button> } </div> <!-- Calendar Legend --> <div class="border-border/60 text-muted-foreground flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs" > <div class="flex items-center gap-1.5"> <span class="bg-success size-2 shrink-0 rounded-full"></span> <span>Approved Leave</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-warning size-2 shrink-0 rounded-full"></span> <span>Pending Approval</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-chart-2 size-2 shrink-0 rounded-full"></span> <span>Company Holiday</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-info size-2 shrink-0 rounded-full"></span> <span>Team OOO</span> </div> </div> </div> </div> <!-- New Leave Request Card --> <div ui-card class="shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div> <h3 ui-card-title class="text-base font-semibold">New Leave Request</h3> <p ui-card-description class="text-xs"> Fill in your planned time off dates and submit for approval. </p> </div> <span ui-badge variant="secondary" class="text-xs font-medium">18 PTO Days Left</span> </div> </div> <div ui-card-content class="space-y-4"> @if (formSubmitted()) { <div class="border-success/20 bg-success/10 text-success dark:text-foreground flex items-start gap-2.5 rounded-lg border p-3" > <lucide-icon [img]="CheckCircle2" class="text-success mt-0.5 size-4 shrink-0" /> <div class="text-xs"> <p class="font-semibold">Leave request submitted successfully!</p> <p class="text-success/80 mt-0.5">Your manager (Sarah Connor) has been notified for review.</p> </div> </div> } <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Leave Category</label> <ui-select [value]="formLeaveType()" (valueChange)="formLeaveType.set($event)"> <button ui-select-trigger> <ui-select-value placeholder="Select category" /> </button> <ui-select-content> <ui-select-item value="vacation">Paid Time Off (PTO) — 18 days available</ui-select-item> <ui-select-item value="sick">Sick Leave — 8 days available</ui-select-item> <ui-select-item value="personal">Personal / Wellness Day — 3 days available</ui-select-item> <ui-select-item value="volunteer">Volunteer & Compassionate — 2 days available</ui-select-item> </ui-select-content> </ui-select> </div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Start Date</label> <ui-input [value]="formStartDate()" (valueChange)="formStartDate.set($event)" type="date" /> </div> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">End Date</label> <ui-input [value]="formEndDate()" (valueChange)="formEndDate.set($event)" type="date" /> </div> </div> <div class="bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 text-sm"> <div class="flex items-center gap-2"> <lucide-icon [img]="Clock" class="text-muted-foreground size-4 shrink-0" /> <span class="text-muted-foreground">Total:</span> <span class="font-semibold tabular-nums" >{{ calculatedDays() }} working days ({{ calculatedDays() * 8 }} hrs)</span > </div> <span ui-badge variant="outline" class="text-xs tabular-nums"> {{ remainingAfterRequest() }} days remaining </span> </div> <div class="space-y-2"> <label class="text-foreground text-sm font-medium">Notes & Coverage</label> <ui-textarea [value]="formNotes()" (valueChange)="formNotes.set($event)" placeholder="Mention handover details or team coverage notes..." class="min-h-[4.5rem]" /> </div> </div> <div ui-card-footer class="flex items-center justify-between border-t pt-4"> <button ui-button variant="ghost" size="sm" (click)="handleResetForm()">Reset</button> <button ui-button size="sm" class="gap-1.5" (click)="handleSubmitRequest()"> <lucide-icon [img]="Send" class="size-3.5 shrink-0" /> Submit Request </button> </div> </div> </div> <!-- Right Panel: Pending Approvals, Team OOO & Company Holidays --> <div class="space-y-6 lg:col-span-5"> <!-- Pending Approvals Card --> <div ui-card class="shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="Users" class="text-muted-foreground size-4 shrink-0" /> <h3 ui-card-title class="text-base font-semibold">Pending Approvals</h3> </div> <span ui-badge variant="secondary" class="text-xs">2 Awaiting Review</span> </div> <p ui-card-description class="text-xs">Direct report requests requiring your confirmation.</p> </div> <div ui-card-content class="space-y-3"> @for (req of pendingRequests(); track req.id) { <div class="bg-muted/30 hover:bg-muted/50 rounded-lg border p-3.5 transition-colors"> <div class="flex items-start justify-between gap-2"> <div class="flex items-center gap-2.5"> <ui-avatar class="size-8 shrink-0"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-medium"> {{ req.avatar }} </ui-avatar-fallback> </ui-avatar> <div> <p class="text-sm leading-none font-medium">{{ req.name }}</p> <p class="text-muted-foreground mt-1 text-xs">{{ req.role }}</p> </div> </div> <span ui-badge [class]="typeBadgeClass(req.typeBadgeClass)"> {{ req.type }} </span> </div> <div class="text-muted-foreground mt-3 flex items-center gap-1.5 text-xs"> <lucide-icon [img]="CalendarDays" class="size-3.5 shrink-0" /> <span class="text-foreground font-medium">{{ req.dateRange }}</span> <span>·</span> <span class="tabular-nums">{{ req.duration }}</span> </div> <p class="text-muted-foreground/90 mt-1.5 text-xs italic">"{{ req.notes }}"</p> <div class="mt-3.5 flex items-center justify-end gap-2 border-t pt-2.5"> @if (req.status === 'pending') { <button ui-button variant="outline" size="sm" class="h-7 text-xs" (click)="declineRequest(req.id)" > <lucide-icon [img]="X" class="size-3 shrink-0" /> Decline </button> <button ui-button size="sm" class="h-7 text-xs" (click)="approveRequest(req.id)"> <lucide-icon [img]="Check" class="size-3 shrink-0" /> Approve </button> } @else if (req.status === 'approved') { <span ui-badge class="border-success/30 bg-success/15 text-success gap-1 text-xs"> <lucide-icon [img]="Check" class="size-3 shrink-0" /> Approved by you </span> } @else { <span ui-badge variant="destructive" class="gap-1 text-xs"> <lucide-icon [img]="X" class="size-3 shrink-0" /> Declined </span> } </div> </div> } </div> </div> <!-- Upcoming Team Out-Of-Office Widget --> <div ui-card class="shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center gap-2"> <lucide-icon [img]="CalendarDays" class="text-muted-foreground size-4 shrink-0" /> <h3 ui-card-title class="text-base font-semibold">Team Out-Of-Office</h3> </div> <p ui-card-description class="text-xs">Teammates away this week and upcoming.</p> </div> <div ui-card-content class="space-y-3"> @for (member of teamOOO; track member.id) { <div class="flex items-center justify-between gap-3 rounded-lg border p-2.5 text-xs"> <div class="flex items-center gap-2.5"> <ui-avatar class="size-7 shrink-0"> <ui-avatar-fallback class="bg-muted text-foreground text-xs font-medium"> {{ member.avatar }} </ui-avatar-fallback> </ui-avatar> <div class="min-w-0"> <p class="text-foreground truncate font-medium">{{ member.name }}</p> <p class="text-muted-foreground truncate">{{ member.role }}</p> </div> </div> <div class="shrink-0 text-right"> <span ui-badge [class]="oooBadgeClass(member.typeClass)"> {{ member.type }} </span> <p class="text-muted-foreground mt-1 text-xs font-medium tabular-nums">{{ member.returnNote }}</p> </div> </div> } </div> </div> <!-- Upcoming Company Holidays --> <div ui-card class="shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center gap-2"> <lucide-icon [img]="CalendarDays" class="text-muted-foreground size-4 shrink-0" /> <h3 ui-card-title class="text-base font-semibold">Company Holidays</h3> </div> <p ui-card-description class="text-xs">Paid corporate office closures in 2026–2027.</p> </div> <div ui-card-content class="space-y-2.5"> @for (holiday of holidays; track holiday.id) { <div class="bg-muted/20 hover:bg-muted/40 flex items-center justify-between rounded-lg border p-2.5 text-xs transition-colors" > <div> <p class="text-foreground font-medium">{{ holiday.name }}</p> <p class="text-muted-foreground mt-0.5">{{ holiday.date }}</p> </div> <span ui-badge variant="outline" class="text-xs font-normal"> {{ holiday.duration }} </span> </div> } </div> </div> </div> </div> </div> `, }) export class UiTimeOffPortalComponent { protected readonly CalendarDays = CalendarDays protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly Clock = Clock protected readonly Info = Info protected readonly Plus = Plus protected readonly Send = Send protected readonly Users = Users protected readonly X = X protected readonly balances = ptoBalances protected readonly days = calendarDays protected readonly teamOOO = teamMembersOOO protected readonly holidays = companyHolidays @Input('title') set heading(v: string | undefined) { if (v !== undefined) this.title.set(v) } @Input('subtitle') set subheading(v: string | undefined) { if (v !== undefined) this.subtitle.set(v) } @Input('class') className?: string readonly title = signal('Time Off & Leave') readonly subtitle = signal('Manage PTO, submit vacation requests, and check team availability.') readonly currentMonth = signal('September 2026') readonly selectedDate = signal('2026-09-14') readonly isDialogOpen = signal(false) readonly formLeaveType = signal('vacation') readonly formStartDate = signal('2026-09-14') readonly formEndDate = signal('2026-09-18') readonly formNotes = signal('Annual family trip. Liam Vance is available for emergency support.') readonly formSubmitted = signal(false) readonly pendingRequests = signal<PendingRequest[]>(defaultPendingRequests.map((r) => ({ ...r }))) readonly calculatedDays = computed(() => countWorkingDays(this.formStartDate(), this.formEndDate())) readonly remainingAfterRequest = computed(() => { const currentAvailable = ptoBalances[0]?.available ?? 18 return Math.max(0, currentAvailable - this.calculatedDays()) }) get rootClass(): string { return cn('w-full space-y-6', this.className) } balanceIconClass(colorClass: string): string { return cn('flex size-8 shrink-0 items-center justify-center rounded-md', colorClass) } typeBadgeClass(extra: string): string { return cn('text-xs capitalize', extra) } oooBadgeClass(extra: string): string { return cn('text-xs font-normal', extra) } cellClass(cell: CalendarCell): string { return cn( 'focus-visible:ring-ring relative flex min-h-[4.25rem] flex-col justify-between rounded-md border p-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none', cell.inMonth ? 'bg-card' : 'bg-muted/20 text-muted-foreground/40 border-dashed', this.selectedDate() === cell.dateKey && 'ring-primary border-transparent ring-2', cell.eventType === 'approved' && 'border-success/30 bg-success/10 text-success', cell.eventType === 'holiday' && 'border-chart-2/30 bg-chart-2/10 text-chart-2', cell.eventType === 'pending' && 'border-warning/30 bg-warning/10 text-warning', cell.eventType === 'team' && 'border-info/30 bg-info/10 text-info', ) } dayNumberClass(cell: CalendarCell): string { return cn( 'text-xs font-medium tabular-nums', cell.dateKey === '2026-09-14' && 'bg-primary text-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold', !cell.inMonth && 'text-muted-foreground/40', ) } dotClass(eventType: NonNullable<CalendarCell['eventType']>): string { return cn( 'size-1.5 shrink-0 rounded-full', eventType === 'approved' && 'bg-success', eventType === 'holiday' && 'bg-chart-2', eventType === 'pending' && 'bg-warning', eventType === 'team' && 'bg-info', ) } labelClass(eventType: CalendarCell['eventType']): string { return cn( 'inline-block truncate rounded px-1 py-0.5 text-xs leading-none font-medium', eventType === 'approved' && 'bg-success/20 text-success', eventType === 'holiday' && 'bg-chart-2/20 text-chart-2', eventType === 'pending' && 'bg-warning/20 text-warning', eventType === 'team' && 'bg-info/20 text-info', ) } handleSubmitRequest(): void { this.formSubmitted.set(true) this.isDialogOpen.set(false) } handleResetForm(): void { this.formLeaveType.set('vacation') this.formStartDate.set('2026-09-14') this.formEndDate.set('2026-09-18') this.formNotes.set('') this.formSubmitted.set(false) } approveRequest(id: string): void { this.pendingRequests.update((prev) => prev.map((r) => (r.id === id ? { ...r, status: 'approved' as const } : r))) } declineRequest(id: string): void { this.pendingRequests.update((prev) => prev.map((r) => (r.id === id ? { ...r, status: 'declined' as const } : r))) } }src/app/components/blocks/time-off-portal/index.ts0.2 kB
export { UiTimeOffPortalComponent, type CalendarCell, type CompanyHoliday, type PendingRequest, type TeamMemberOOO, calendarDays, companyHolidays, countWorkingDays, teamMembersOOO, } from './time-off-portal.component'
Raw manifest:https://uipkge.dev/r/angular/time-off-portal.json