Menu

Change language

GitHub repo

Time Off Portal

blockhr

Employee 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

$npx uipkge-ng@latest add time-off-portal
Manifest:https://uipkge.dev/r/angular/time-off-portal.jsonInstalls to:src/app/components/blocks/time-off-portal/

Variants

Loading interactive previews…

Schema

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

PendingRequest
interface PendingRequest {
  id: string
  name: string
  role: string
  avatar: string
  dateRange: string
  duration: string
  type: string
  typeBadgeClass: string
  notes: string
  status: 'pending' | 'approved' | 'declined'
}
TeamMemberOOO
interface TeamMemberOOO {
  id: string
  name: string
  role: string
  avatar: string
  type: string
  typeClass: string
  dates: string
  returnNote: string
}
CompanyHoliday
interface CompanyHoliday {
  id: string
  name: string
  date: string
  duration: string
  isLongWeekend?: boolean
}
CalendarCell
interface CalendarCell {
  dateKey: string
  dayNumber: number
  inMonth: boolean
  eventType: 'approved' | 'holiday' | 'pending' | 'team' | null
  label: string
}
PtoBalance
interface 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

Raw manifest:https://uipkge.dev/r/angular/time-off-portal.json