Menu

Change language

GitHub repo

Maintenance Request Ticket

blockreal-estate

Resident repair and facility work order submission portal with urgency priority and photo dropzone.

Installation

$npx uipkge-ng@latest add maintenance-request-ticket
Manifest:https://uipkge.dev/r/angular/maintenance-request-ticket.jsonInstalls to:src/app/components/blocks/maintenance-request-ticket/

Variants

Loading interactive previews…

Schema

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

AttachedFile
interface AttachedFile {
  id: string
  name: string
  size: string
  type: 'image' | 'video'
  uploadedAt: string
}
IssueCategory
interface IssueCategory {
  id: string
  label: string
  description: string
  icon: LucideIconData
  badgeColor: string
}
UrgencyOption
interface UrgencyOption {
  value: MaintenanceUrgency
  label: string
  badge: string
  badgeVariant: 'outline' | 'secondary' | 'destructive'
  description: string
}

Files installed (2)

  • src/app/components/blocks/maintenance-request-ticket/maintenance-request-ticket.component.ts44.9 kB
    // Resident maintenance request ticket: category grid, urgency radios with an
    // emergency SLA banner, unit location select, description with quick snippets,
    // entry-permission radios, photo/video dropzone, draft + submit flow, and an
    // emergency-hotline / SLA / building-operations sidebar. Port of the Vue block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      AlertCircle,
      Bug,
      Building2,
      Check,
      CheckCircle2,
      ChevronDown,
      Clock,
      Droplets,
      FileImage,
      FileVideo,
      Flame,
      KeyRound,
      LucideAngularModule,
      PhoneCall,
      Plus,
      RefreshCw,
      ShieldAlert,
      ShieldCheck,
      Trash2,
      UploadCloud,
      UserCheck,
      Wrench,
      Zap,
      type LucideIconData,
    } 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,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiRadioGroupComponent, UiRadioGroupItemComponent } from '@/ui/radio-group/radio-group.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectGroupComponent,
      UiSelectItemComponent,
      UiSelectLabelComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export interface AttachedFile {
      id: string
      name: string
      size: string
      type: 'image' | 'video'
      uploadedAt: string
    }
    
    export type MaintenanceUrgency = 'routine' | 'standard' | 'emergency'
    export type EntryPermission = 'granted' | 'call_first'
    
    interface IssueCategory {
      id: string
      label: string
      description: string
      icon: LucideIconData
      badgeColor: string
    }
    
    const categories: IssueCategory[] = [
      {
        id: 'plumbing',
        label: 'Plumbing & Leaks',
        description: 'Faucets, pipes, toilets, drains',
        icon: Droplets,
        badgeColor: 'text-info bg-info/10 text-info',
      },
      {
        id: 'electrical',
        label: 'Electrical & Lighting',
        description: 'Outlets, switches, fixtures, breakers',
        icon: Zap,
        badgeColor: 'text-warning bg-warning/10 text-warning',
      },
      {
        id: 'hvac',
        label: 'HVAC & AC Heating',
        description: 'AC, thermostat, heater, airflow',
        icon: Flame,
        badgeColor: 'text-chart-2 bg-chart-2/10',
      },
      {
        id: 'appliances',
        label: 'Appliances',
        description: 'Dishwasher, oven, disposal, fridge',
        icon: Wrench,
        badgeColor: 'text-chart-1 bg-chart-1/10',
      },
      {
        id: 'locks',
        label: 'Doors & Locks',
        description: 'Keys, latches, deadbolts, windows',
        icon: KeyRound,
        badgeColor: 'text-success bg-success/10 text-success',
      },
      {
        id: 'pest',
        label: 'Pest Control',
        description: 'Insects, rodents, preventative traps',
        icon: Bug,
        badgeColor: 'text-destructive bg-destructive/10 text-destructive',
      },
    ]
    
    interface UrgencyOption {
      value: MaintenanceUrgency
      label: string
      badge: string
      badgeVariant: 'outline' | 'secondary' | 'destructive'
      description: string
    }
    
    const urgencyOptions: UrgencyOption[] = [
      {
        value: 'routine',
        label: 'Low / Routine',
        badge: '3–5 Business Days',
        badgeVariant: 'outline',
        description: 'Non-urgent preventative maintenance or routine hardware adjustments.',
      },
      {
        value: 'standard',
        label: 'Medium / Standard 48h',
        badge: '24–48h SLA',
        badgeVariant: 'secondary',
        description: 'Standard repair with moderate impact on daily convenience or appliance usage.',
      },
      {
        value: 'emergency',
        label: 'High / Emergency 2h SLA',
        badge: 'Emergency < 2h',
        badgeVariant: 'destructive',
        description: 'Immediate hazard to property or habitability. Triggers 24/7 on-call dispatch.',
      },
    ]
    
    const locationOptions: { value: string; label: string }[] = [
      { value: 'kitchen', label: 'Kitchen' },
      { value: 'master_bathroom', label: 'Master Bathroom' },
      { value: 'guest_bathroom', label: 'Guest Bathroom' },
      { value: 'living_room', label: 'Living Room' },
      { value: 'balcony', label: 'Balcony / Patio' },
      { value: 'master_bedroom', label: 'Master Bedroom' },
      { value: 'guest_bedroom', label: 'Guest Bedroom' },
      { value: 'hallway', label: 'Hallway / Entryway' },
      { value: 'laundry', label: 'Laundry & Utility Closet' },
    ]
    
    const quickSnippets: string[] = [
      'Motor humming but not spinning',
      'Water leaking under sink',
      'Reset button tripped',
      'Slow draining',
    ]
    
    const defaultDescription =
      'Kitchen sink garbage disposal is jammed and leaking slightly under the cabinet when water runs. Motor makes a low humming sound.'
    
    const defaultFiles: AttachedFile[] = [
      {
        id: 'f-1',
        name: 'kitchen-sink-leak-pipe.jpg',
        size: '2.4 MB',
        type: 'image',
        uploadedAt: 'Today, 10:14 AM',
      },
      {
        id: 'f-2',
        name: 'disposal-motor-hum.mp4',
        size: '8.1 MB',
        type: 'video',
        uploadedAt: 'Today, 10:16 AM',
      },
    ]
    
    const maxChars = 500
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-maintenance-request-ticket, [ui-maintenance-request-ticket]',
      standalone: true,
      // The block renders its own root <div>: keep the host out of layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiRadioGroupComponent,
        UiRadioGroupItemComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectGroupComponent,
        UiSelectItemComponent,
        UiSelectLabelComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="maintenance-request-ticket" [class]="rootClass">
          <!-- Header Banner -->
          <div ui-card class="border-border shadow-xs">
            <div ui-card-content class="p-4 sm:p-6">
              <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
                <div class="space-y-1.5">
                  <div class="flex flex-wrap items-center gap-2.5">
                    <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Submit Maintenance Request</h1>
                    <span ui-badge variant="outline" class="gap-1.5 font-mono text-xs">
                      <span class="bg-success size-1.5 rounded-full"></span>
                      Unit 4B · Pacific Heights
                    </span>
                  </div>
                  <p class="text-muted-foreground text-sm">
                    Resident: <span class="text-foreground font-medium">Elena Rostova</span> · Facility maintenance &amp;
                    repair portal
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-2.5">
                  <div
                    class="bg-muted/60 border-border inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
                  >
                    <span class="relative flex size-2">
                      <span class="bg-warning absolute inline-flex size-full rounded-full opacity-75"></span>
                      <span class="bg-warning relative inline-flex size-2 rounded-full"></span>
                    </span>
                    <span class="text-foreground font-medium">1 In Progress</span>
                    <span class="text-muted-foreground hidden sm:inline">· #MNT-8821</span>
                  </div>
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="gap-1.5 shadow-xs"
                    [attr.aria-expanded]="showOpenRequests()"
                    (click)="showOpenRequests.set(!showOpenRequests())"
                  >
                    <span>{{ showOpenRequests() ? 'Hide Open Requests' : 'View Open Requests' }}</span>
                    <lucide-icon [img]="ChevronDown" [class]="openRequestsChevronClass" />
                  </button>
                </div>
              </div>
    
              <!-- Collapsible Active Work Orders Panel -->
              @if (showOpenRequests()) {
                <div class="border-border bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 transition-colors">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Clock" class="text-muted-foreground size-4" />
                      <span class="text-foreground text-xs font-semibold tracking-wider uppercase">
                        Currently Active Work Orders (1)
                      </span>
                    </div>
                    <span ui-badge variant="secondary" class="text-xs">Technician Assigned</span>
                  </div>
                  <div
                    class="bg-card border-border flex flex-col justify-between gap-3 rounded-md border p-3 sm:flex-row sm:items-center"
                  >
                    <div class="space-y-1">
                      <div class="flex items-center gap-2">
                        <span class="text-foreground text-sm font-semibold"
                          >#MNT-8821 · HVAC Filter &amp; Thermostat Inspection</span
                        >
                        <span ui-badge variant="outline" class="text-xs">Living Room</span>
                      </div>
                      <p class="text-muted-foreground text-xs">
                        Assigned to: <span class="text-foreground font-medium">Dave Miller (Lead HVAC Specialist)</span> ·
                        Scheduled window: Today 2:00 PM – 4:00 PM
                      </p>
                    </div>
                    <div class="flex shrink-0 items-center gap-2">
                      <span ui-badge variant="info" class="text-xs">In Progress</span>
                    </div>
                  </div>
                </div>
              }
            </div>
          </div>
    
          <!-- Main 2-Column Layout -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-8">
            <!-- Left Column: Work Order Form (8 cols) -->
            <div class="space-y-6 lg:col-span-8">
              @if (isSubmitted()) {
                <!-- Submission Success State -->
                <div ui-card class="border-success/30 bg-success/5 dark:bg-success/10 shadow-xs">
                  <div ui-card-content class="space-y-4 p-6 text-center sm:p-8">
                    <div class="bg-success/10 text-success mx-auto flex size-14 items-center justify-center rounded-full">
                      <lucide-icon [img]="CheckCircle2" class="size-8" />
                    </div>
                    <div class="space-y-2">
                      <h2 class="text-foreground text-xl font-bold">Maintenance Request Submitted!</h2>
                      <p class="text-muted-foreground mx-auto max-w-md text-sm">
                        Your request <span class="text-foreground font-mono font-semibold">#{{ generatedTicketId() }}</span>
                        has been logged and dispatched to the Pacific Heights Facility Operations team.
                      </p>
                    </div>
                    <div class="border-border bg-card mx-auto max-w-lg space-y-2 rounded-lg border p-4 text-left text-xs">
                      <div class="flex justify-between">
                        <span class="text-muted-foreground">Ticket ID:</span>
                        <span class="text-foreground font-mono font-medium">#{{ generatedTicketId() }}</span>
                      </div>
                      <div class="flex justify-between">
                        <span class="text-muted-foreground">Unit:</span>
                        <span class="text-foreground font-medium">Unit 4B (Pacific Heights)</span>
                      </div>
                      <div class="flex justify-between">
                        <span class="text-muted-foreground">Category:</span>
                        <span class="text-foreground font-medium capitalize">{{ category() }}</span>
                      </div>
                      <div class="flex justify-between">
                        <span class="text-muted-foreground">Urgency Tier:</span>
                        <span class="text-foreground font-medium capitalize">{{ urgency() }} SLA</span>
                      </div>
                      <div class="flex justify-between">
                        <span class="text-muted-foreground">Entry Authorization:</span>
                        <span class="text-foreground font-medium">
                          {{ permission() === 'granted' ? 'Permission Granted (Master Key)' : 'Call Resident First' }}
                        </span>
                      </div>
                    </div>
                    <div class="flex flex-wrap justify-center gap-3 pt-2">
                      <button ui-button class="gap-1.5 shadow-xs" (click)="handleReset()">
                        <lucide-icon [img]="RefreshCw" class="size-4" />
                        <span>Submit Another Request</span>
                      </button>
                    </div>
                  </div>
                </div>
              } @else {
                <!-- Work Order Submission Form -->
                <div ui-card class="border-border shadow-xs">
                  <div ui-card-header class="pb-4">
                    <h3 ui-card-title class="text-lg">Work Order Details</h3>
                    <p ui-card-description>Complete the fields below to schedule a maintenance technician visit.</p>
                  </div>
    
                  <div ui-card-content class="space-y-6">
                    <!-- 1. Category Selection Grid -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label class="text-foreground text-sm font-semibold">
                          1. Issue Category <span class="text-destructive">*</span>
                        </label>
                        <span class="text-muted-foreground text-xs">Select one primary discipline</span>
                      </div>
    
                      <div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
                        @for (cat of allCategories; track cat.id) {
                          <button
                            type="button"
                            [attr.aria-pressed]="category() === cat.id"
                            [class]="categoryButtonClass(cat.id)"
                            (click)="selectCategory(cat.id)"
                          >
                            <div class="flex w-full items-center justify-between">
                              <div [class]="categoryIconClass(cat)">
                                <lucide-icon [img]="cat.icon" class="size-4.5" />
                              </div>
                              <div [class]="categoryCheckClass(cat.id)">
                                @if (category() === cat.id) {
                                  <lucide-icon [img]="Check" class="size-2.5 stroke-[3]" />
                                }
                              </div>
                            </div>
                            <div>
                              <p class="text-foreground text-xs font-semibold">{{ cat.label }}</p>
                              <p class="text-muted-foreground mt-0.5 line-clamp-1 text-xs">{{ cat.description }}</p>
                            </div>
                          </button>
                        }
                      </div>
                    </div>
    
                    <ui-separator />
    
                    <!-- 2. Urgency Level Radios -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label class="text-foreground text-sm font-semibold">
                          2. Urgency Priority <span class="text-destructive">*</span>
                        </label>
                        <span class="text-muted-foreground text-xs">Determines facility response window</span>
                      </div>
    
                      <!-- Emergency Banner Alert if High Selected -->
                      @if (urgency() === 'emergency') {
                        <div
                          class="border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-3 rounded-lg border p-3.5 text-xs"
                        >
                          <lucide-icon [img]="AlertCircle" class="mt-0.5 size-4 shrink-0" />
                          <div class="space-y-1">
                            <p class="font-semibold">High / Emergency SLA Activated</p>
                            <p class="text-destructive/90 leading-relaxed">
                              Emergency requests notify on-call building engineers immediately. For active gas leaks or
                              major structural flooding, call the 24/7 hotline directly at
                              <strong>(415) 555-0192</strong>.
                            </p>
                          </div>
                        </div>
                      }
    
                      <ui-radio-group
                        [value]="urgency()"
                        (valueChange)="setUrgency($event)"
                        class="grid gap-3 sm:grid-cols-3"
                      >
                        @for (opt of urgencyOpts; track opt.value) {
                          <div [class]="urgencyCardClass(opt.value)" (click)="setUrgency(opt.value)">
                            <div class="flex items-start justify-between gap-2">
                              <div class="flex items-center gap-2">
                                <ui-radio-group-item [id]="'angular-urgency-' + opt.value" [value]="opt.value" />
                                <label
                                  [for]="'angular-urgency-' + opt.value"
                                  class="text-foreground cursor-pointer text-xs font-semibold select-none"
                                >
                                  {{ opt.label }}
                                </label>
                              </div>
                            </div>
                            <div>
                              <span ui-badge [variant]="opt.badgeVariant" class="mb-1 text-xs">
                                {{ opt.badge }}
                              </span>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                {{ opt.description }}
                              </p>
                            </div>
                          </div>
                        }
                      </ui-radio-group>
                    </div>
    
                    <ui-separator />
    
                    <!-- 3. Location in Unit -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label class="text-foreground text-sm font-semibold">
                          3. Location in Unit <span class="text-destructive">*</span>
                        </label>
                        <span class="text-muted-foreground text-xs">Specific area or room</span>
                      </div>
    
                      <ui-select [value]="location()" (valueChange)="location.set($event)">
                        <button ui-select-trigger class="w-full" aria-label="Select location in unit">
                          <ui-select-value placeholder="Select location in unit..." />
                        </button>
                        <ui-select-content>
                          <ui-select-group>
                            <ui-select-label>Unit 4B Interior Areas</ui-select-label>
                            @for (loc of locationOpts; track loc.value) {
                              <ui-select-item [value]="loc.value">{{ loc.label }}</ui-select-item>
                            }
                          </ui-select-group>
                        </ui-select-content>
                      </ui-select>
                    </div>
    
                    <ui-separator />
    
                    <!-- 4. Issue Description -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label for="angular-mnt-description" class="text-foreground text-sm font-semibold">
                          4. Issue Description <span class="text-destructive">*</span>
                        </label>
                        <span [class]="descriptionCountClass"> {{ descriptionLength() }} / {{ charLimit }} </span>
                      </div>
    
                      <ui-textarea
                        id="angular-mnt-description"
                        [value]="description()"
                        (valueChange)="description.set($event)"
                        [rows]="4"
                        placeholder="Please describe the maintenance issue with as much detail as possible..."
                        class="w-full text-sm"
                      />
    
                      <!-- Quick Snippet Helper Chips -->
                      <div class="space-y-1.5">
                        <p class="text-muted-foreground text-xs">Quick details:</p>
                        <div class="flex flex-wrap gap-1.5">
                          @for (chip of snippets; track chip) {
                            <button
                              type="button"
                              class="bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 rounded-md px-2.5 py-1 text-xs transition-colors"
                              (click)="appendSnippet(chip)"
                            >
                              <lucide-icon [img]="Plus" class="size-3" />
                              <span>{{ chip }}</span>
                            </button>
                          }
                        </div>
                      </div>
                    </div>
    
                    <ui-separator />
    
                    <!-- 5. Permission to Enter Unit -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label class="text-foreground text-sm font-semibold">
                          5. Permission to Enter Unit <span class="text-destructive">*</span>
                        </label>
                        <span class="text-muted-foreground text-xs">Access protocol</span>
                      </div>
    
                      <ui-radio-group
                        [value]="permission()"
                        (valueChange)="setPermission($event)"
                        class="grid gap-3 sm:grid-cols-2"
                      >
                        <div [class]="permissionCardClass('granted')" (click)="permission.set('granted')">
                          <div class="flex items-start gap-2.5">
                            <ui-radio-group-item id="angular-perm-granted" value="granted" class="mt-0.5" />
                            <div class="space-y-1">
                              <label
                                for="angular-perm-granted"
                                class="text-foreground cursor-pointer text-xs font-semibold select-none"
                              >
                                Permission Granted to Enter
                              </label>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                Authorized staff may enter with master key if resident is not home. Work order sign-off
                                notice will be left in unit.
                              </p>
                            </div>
                          </div>
                          <div class="text-success flex items-center gap-1.5 pl-6 text-xs font-medium">
                            <lucide-icon [img]="UserCheck" class="size-3.5" />
                            <span>Faster dispatch window</span>
                          </div>
                        </div>
    
                        <div [class]="permissionCardClass('call_first')" (click)="permission.set('call_first')">
                          <div class="flex items-start gap-2.5">
                            <ui-radio-group-item id="angular-perm-call" value="call_first" class="mt-0.5" />
                            <div class="space-y-1">
                              <label
                                for="angular-perm-call"
                                class="text-foreground cursor-pointer text-xs font-semibold select-none"
                              >
                                Call Resident Before Entering
                              </label>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                Technician must call <span class="text-foreground font-medium">(415) 890-4412</span> 30 mins
                                prior to arrival. Adult must be present.
                              </p>
                            </div>
                          </div>
                          <div class="text-muted-foreground flex items-center gap-1.5 pl-6 text-xs">
                            <lucide-icon [img]="Clock" class="size-3.5" />
                            <span>Requires resident appointment</span>
                          </div>
                        </div>
                      </ui-radio-group>
                    </div>
    
                    <ui-separator />
    
                    <!-- 6. Photo & Video Dropzone -->
                    <div class="space-y-3">
                      <div class="flex items-center justify-between">
                        <label class="text-foreground text-sm font-semibold">
                          6. Photo &amp; Video Dropzone
                          <span class="text-muted-foreground text-xs font-normal">(Optional)</span>
                        </label>
                        <span class="text-muted-foreground text-xs">{{ attachedFiles().length }} Attached</span>
                      </div>
    
                      <!-- Interactive Drop Area -->
                      <div
                        [class]="dropzoneClass"
                        (dragover)="onDragOver($event)"
                        (dragleave)="onDragLeave($event)"
                        (drop)="handleDrop($event)"
                        (click)="simulateAddFile()"
                      >
                        <div
                          class="bg-muted text-muted-foreground mx-auto flex size-10 items-center justify-center rounded-full"
                        >
                          <lucide-icon [img]="UploadCloud" class="size-5" />
                        </div>
                        <div class="mt-2.5 space-y-1">
                          <p class="text-foreground text-xs font-medium">
                            <span class="text-primary font-semibold hover:underline">Click to upload</span> or drag and drop
                            media files
                          </p>
                          <p class="text-muted-foreground text-xs">
                            PNG, JPG, HEIC, MP4 up to 25MB (helps technician arrive with correct parts)
                          </p>
                        </div>
                      </div>
    
                      <!-- Attached Files Previews -->
                      @if (attachedFiles().length > 0) {
                        <div class="space-y-2 pt-1">
                          @for (f of attachedFiles(); track f.id) {
                            <div
                              class="bg-card border-border flex items-center justify-between gap-3 rounded-lg border p-2.5 text-xs transition-colors"
                            >
                              <div class="flex min-w-0 items-center gap-2.5">
                                <div
                                  class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md"
                                >
                                  @if (f.type === 'video') {
                                    <lucide-icon [img]="FileVideo" class="size-4" />
                                  } @else {
                                    <lucide-icon [img]="FileImage" class="size-4" />
                                  }
                                </div>
                                <div class="min-w-0">
                                  <p class="text-foreground truncate font-medium">{{ f.name }}</p>
                                  <p class="text-muted-foreground text-xs">{{ f.size }} · {{ f.uploadedAt }}</p>
                                </div>
                              </div>
                              <div class="flex shrink-0 items-center gap-2">
                                <span ui-badge variant="outline" class="border-success/30 text-success text-xs">
                                  Attached
                                </span>
                                <button
                                  ui-button
                                  variant="ghost"
                                  size="icon-sm"
                                  class="text-muted-foreground hover:text-destructive size-7"
                                  [attr.aria-label]="'Remove file ' + f.name"
                                  (click)="removeFile(f.id)"
                                >
                                  <lucide-icon [img]="Trash2" class="size-3.5" />
                                  <span class="sr-only">Remove file</span>
                                </button>
                              </div>
                            </div>
                          }
                        </div>
                      }
                    </div>
                  </div>
    
                  <div
                    ui-card-footer
                    class="border-border bg-muted/20 flex flex-col-reverse justify-between gap-3 border-t p-4 sm:flex-row sm:items-center"
                  >
                    <div class="flex items-center gap-2">
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="shadow-xs"
                        [disabled]="isSubmitting()"
                        (click)="handleSaveDraft()"
                      >
                        <span>{{ draftSaved() ? 'Draft Saved!' : 'Save as Draft' }}</span>
                      </button>
                      @if (draftSaved()) {
                        <span class="text-success flex items-center gap-1 text-xs">
                          <lucide-icon [img]="Check" class="size-3" />
                          Saved locally
                        </span>
                      }
                    </div>
    
                    <button
                      ui-button
                      size="default"
                      class="w-full gap-2 shadow-xs sm:w-auto"
                      [disabled]="isSubmitting() || !description().trim()"
                      (click)="handleSubmit()"
                    >
                      @if (!isSubmitting()) {
                        <lucide-icon [img]="Wrench" class="size-4" />
                      } @else {
                        <lucide-icon [img]="RefreshCw" class="size-4 animate-spin" />
                      }
                      <span>{{ isSubmitting() ? 'Dispatching Work Order...' : 'Submit Maintenance Request' }}</span>
                    </button>
                  </div>
                </div>
              }
            </div>
    
            <!-- Right Column: Emergency & SLA Sidebar (4 cols) -->
            <div class="space-y-6 lg:sticky lg:top-6 lg:col-span-4">
              <!-- 24/7 Emergency Maintenance Hotline Card -->
              <div ui-card class="border-destructive/40 bg-destructive/5 dark:bg-destructive/10 shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="text-destructive flex items-center gap-2">
                    <lucide-icon [img]="ShieldAlert" class="size-5 shrink-0" />
                    <h3 ui-card-title class="text-destructive text-base">24/7 Emergency Hotline</h3>
                  </div>
                  <p ui-card-description class="text-foreground/80 text-xs">
                    For urgent situations threatening life safety, gas leaks, or active flooding.
                  </p>
                </div>
                <div ui-card-content class="space-y-4">
                  <div class="bg-card border-destructive/30 space-y-1 rounded-lg border p-3 text-center">
                    <p class="text-muted-foreground text-xs font-medium">Immediate Dispatch Phone</p>
                    <p class="text-foreground font-mono text-xl font-semibold tracking-tight">(415) 555-0192</p>
                    <p class="text-muted-foreground text-xs">Facility Operations On-Call Desk</p>
                  </div>
    
                  <div class="space-y-2">
                    <p class="text-foreground text-xs font-semibold">What qualifies as an emergency:</p>
                    <ul class="text-muted-foreground space-y-1.5 text-xs">
                      <li class="flex items-start gap-2">
                        <span class="text-destructive font-bold">•</span>
                        <span>Active uncontrolled water leaks or burst pipes</span>
                      </li>
                      <li class="flex items-start gap-2">
                        <span class="text-destructive font-bold">•</span>
                        <span>Smell of natural gas or carbon monoxide alert</span>
                      </li>
                      <li class="flex items-start gap-2">
                        <span class="text-destructive font-bold">•</span>
                        <span>Complete electrical power loss in unit</span>
                      </li>
                      <li class="flex items-start gap-2">
                        <span class="text-destructive font-bold">•</span>
                        <span>Inoperable exterior lock or door security issue</span>
                      </li>
                      <li class="flex items-start gap-2">
                        <span class="text-destructive font-bold">•</span>
                        <span>Total loss of heating when temp is below 55°F</span>
                      </li>
                    </ul>
                  </div>
    
                  <a ui-button href="tel:4155550192" variant="destructive" class="w-full gap-2 shadow-xs">
                    <lucide-icon [img]="PhoneCall" class="size-4" />
                    <span>Call Emergency Dispatch</span>
                  </a>
                </div>
              </div>
    
              <!-- Service Level Agreement (SLA) Card -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="Clock" class="text-primary size-4.5" />
                    <h3 ui-card-title class="text-base">Service Level Agreement (SLA)</h3>
                  </div>
                  <p ui-card-description class="text-xs">Response benchmarks for Pacific Heights property maintenance.</p>
                </div>
                <div ui-card-content class="space-y-3.5">
                  <div class="bg-muted/40 border-border space-y-2 rounded-lg border p-3">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2">
                        <span class="bg-destructive size-2 rounded-full"></span>
                        <span class="text-foreground text-xs font-semibold">Emergency Priority</span>
                      </div>
                      <span ui-badge variant="destructive" class="text-xs">&lt; 2 Hours</span>
                    </div>
                    <p class="text-muted-foreground text-xs">
                      24/7/365 immediate dispatch with on-call technician response.
                    </p>
                  </div>
    
                  <div class="bg-muted/40 border-border space-y-2 rounded-lg border p-3">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2">
                        <span class="bg-secondary-foreground size-2 rounded-full"></span>
                        <span class="text-foreground text-xs font-semibold">Standard Priority</span>
                      </div>
                      <span ui-badge variant="secondary" class="text-xs">24–48 Hours</span>
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Standard repairs scheduled Mon–Fri during normal operational hours.
                    </p>
                  </div>
    
                  <div class="bg-muted/40 border-border space-y-2 rounded-lg border p-3">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2">
                        <span class="bg-muted-foreground size-2 rounded-full"></span>
                        <span class="text-foreground text-xs font-semibold">Routine / Preventative</span>
                      </div>
                      <span ui-badge variant="outline" class="text-xs">3–5 Days</span>
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Filter changes, caulking touch-ups, and scheduled inspections.
                    </p>
                  </div>
    
                  <div class="border-border bg-card flex items-start gap-2.5 rounded-lg border p-3 text-xs">
                    <lucide-icon [img]="ShieldCheck" class="text-primary mt-0.5 size-4 shrink-0" />
                    <p class="text-muted-foreground leading-relaxed">
                      Status notifications are automatically sent via SMS and resident email as work orders progress.
                    </p>
                  </div>
                </div>
              </div>
    
              <!-- Building Operations Info Card -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="Building2" class="text-muted-foreground size-4.5" />
                    <h3 ui-card-title class="text-base">Building Operations</h3>
                  </div>
                </div>
                <div ui-card-content class="space-y-2.5 text-xs">
                  <div class="border-border/60 flex justify-between border-b py-1">
                    <span class="text-muted-foreground">Building Super:</span>
                    <span class="text-foreground font-medium">Marcus Vance (Office #102)</span>
                  </div>
                  <div class="border-border/60 flex justify-between border-b py-1">
                    <span class="text-muted-foreground">Service Window:</span>
                    <span class="text-foreground font-medium">Mon – Sat · 8:00 AM – 6:00 PM</span>
                  </div>
                  <div class="border-border/60 flex justify-between border-b py-1">
                    <span class="text-muted-foreground">Quiet Hours:</span>
                    <span class="text-foreground font-medium">10:00 PM – 8:00 AM</span>
                  </div>
                  <div class="flex justify-between py-1">
                    <span class="text-muted-foreground">Resident Portal ID:</span>
                    <span class="text-foreground font-mono font-medium">PH-RES-4B</span>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiMaintenanceRequestTicketComponent {
      protected readonly AlertCircle = AlertCircle
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly ChevronDown = ChevronDown
      protected readonly Clock = Clock
      protected readonly FileImage = FileImage
      protected readonly FileVideo = FileVideo
      protected readonly PhoneCall = PhoneCall
      protected readonly Plus = Plus
      protected readonly RefreshCw = RefreshCw
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Building2 = Building2
      protected readonly Trash2 = Trash2
      protected readonly UploadCloud = UploadCloud
      protected readonly UserCheck = UserCheck
      protected readonly Wrench = Wrench
    
      protected readonly allCategories = categories
      protected readonly urgencyOpts = urgencyOptions
      protected readonly locationOpts = locationOptions
      protected readonly snippets = quickSnippets
      protected readonly charLimit = maxChars
    
      @Input() set initialCategory(v: string) {
        if (v) this.category.set(v)
      }
      @Input() set initialUrgency(v: MaintenanceUrgency) {
        if (v) this.urgency.set(v)
      }
      @Input() set initialLocation(v: string) {
        if (v) this.location.set(v)
      }
      @Input() set initialDescription(v: string) {
        if (v !== undefined && v !== null) this.description.set(v)
      }
      @Input() set initialPermission(v: EntryPermission) {
        if (v) this.permission.set(v)
      }
      @Input() set initialFiles(v: AttachedFile[] | undefined) {
        this.attachedFiles.set((v ?? defaultFiles).map((f) => ({ ...f })))
      }
      @Input('class') className?: string
    
      readonly category = signal<string>('appliances')
      readonly urgency = signal<MaintenanceUrgency>('standard')
      readonly location = signal<string>('kitchen')
      readonly description = signal<string>(defaultDescription)
      readonly permission = signal<EntryPermission>('granted')
      readonly attachedFiles = signal<AttachedFile[]>(defaultFiles.map((f) => ({ ...f })))
      readonly isDragging = signal(false)
      readonly showOpenRequests = signal(false)
      readonly isSubmitting = signal(false)
      readonly isSubmitted = signal(false)
      readonly draftSaved = signal(false)
      readonly generatedTicketId = signal('MNT-9042')
    
      private draftTimer: ReturnType<typeof setTimeout> | undefined
      private submitTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          clearTimeout(this.draftTimer)
          clearTimeout(this.submitTimer)
        })
      }
    
      readonly descriptionLength = computed(() => this.description().length)
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      get openRequestsChevronClass(): string {
        return cn('size-3.5 transition-transform duration-200', this.showOpenRequests() && 'rotate-180')
      }
    
      get descriptionCountClass(): string {
        return cn('text-xs', this.descriptionLength() > maxChars ? 'text-destructive font-medium' : 'text-muted-foreground')
      }
    
      get dropzoneClass(): string {
        return cn(
          'cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors',
          this.isDragging()
            ? 'border-primary bg-primary/10'
            : 'border-border bg-muted/20 hover:border-muted-foreground/40 hover:bg-muted/40',
        )
      }
    
      categoryButtonClass(id: string): string {
        return cn(
          'group focus-visible:ring-ring relative flex cursor-pointer flex-col items-start gap-2.5 rounded-lg border p-3.5 text-left transition-colors outline-none select-none focus-visible:ring-2',
          this.category() === id
            ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1'
            : 'border-border bg-card hover:bg-muted/40 hover:border-muted-foreground/30',
        )
      }
    
      categoryIconClass(cat: IssueCategory): string {
        return cn('flex size-8 items-center justify-center rounded-md', cat.badgeColor)
      }
    
      categoryCheckClass(id: string): string {
        return cn(
          'flex size-4 items-center justify-center rounded-full border transition-colors',
          this.category() === id
            ? 'border-primary bg-primary text-primary-foreground'
            : 'border-muted-foreground/30 opacity-0 group-hover:opacity-60',
        )
      }
    
      urgencyCardClass(value: MaintenanceUrgency): string {
        return cn(
          'border-border relative flex cursor-pointer flex-col justify-between gap-3 rounded-lg border p-3.5 transition-colors',
          this.urgency() === value
            ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1'
            : 'bg-card hover:bg-muted/40 hover:border-muted-foreground/30',
        )
      }
    
      permissionCardClass(value: EntryPermission): string {
        return cn(
          'border-border relative flex cursor-pointer flex-col justify-between gap-2.5 rounded-lg border p-4 transition-colors',
          this.permission() === value
            ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1'
            : 'bg-card hover:bg-muted/40 hover:border-muted-foreground/30',
        )
      }
    
      selectCategory(id: string): void {
        this.category.set(id)
      }
    
      setUrgency(value: string): void {
        this.urgency.set(value as MaintenanceUrgency)
      }
    
      setPermission(value: string): void {
        this.permission.set(value as EntryPermission)
      }
    
      appendSnippet(snippet: string): void {
        const current = this.description()
        if (!current) {
          this.description.set(snippet)
        } else if (!current.includes(snippet)) {
          this.description.set(`${current.trim()} ${snippet}.`)
        }
      }
    
      removeFile(id: string): void {
        this.attachedFiles.update((prev) => prev.filter((f) => f.id !== id))
      }
    
      onDragOver(event: DragEvent): void {
        event.preventDefault()
        this.isDragging.set(true)
      }
    
      onDragLeave(event: DragEvent): void {
        event.preventDefault()
        this.isDragging.set(false)
      }
    
      handleDrop(event: DragEvent): void {
        event.preventDefault()
        this.isDragging.set(false)
        const files = event.dataTransfer?.files
        if (files && files.length > 0) {
          const additions: AttachedFile[] = []
          for (let i = 0; i < files.length; i++) {
            const file = files[i]
            additions.push({
              id: `f-${Date.now()}-${i}`,
              name: file.name,
              size: `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
              type: file.type.startsWith('video') ? 'video' : 'image',
              uploadedAt: 'Just now',
            })
          }
          this.attachedFiles.update((prev) => [...prev, ...additions])
        }
      }
    
      simulateAddFile(): void {
        const sampleNames = ['cabinet-water-mark.jpg', 'under-sink-plumbing.jpg', 'noise-recording.mp4']
        const files = this.attachedFiles()
        const randomName = sampleNames[files.length % sampleNames.length]
        this.attachedFiles.update((prev) => [
          ...prev,
          {
            id: `f-${Date.now()}`,
            name: randomName,
            size: '3.1 MB',
            type: randomName.endsWith('.mp4') ? 'video' : 'image',
            uploadedAt: 'Just now',
          },
        ])
      }
    
      handleSaveDraft(): void {
        this.draftSaved.set(true)
        clearTimeout(this.draftTimer)
        this.draftTimer = setTimeout(() => this.draftSaved.set(false), 3000)
      }
    
      handleSubmit(): void {
        this.isSubmitting.set(true)
        clearTimeout(this.submitTimer)
        this.submitTimer = setTimeout(() => {
          this.isSubmitting.set(false)
          this.isSubmitted.set(true)
          this.generatedTicketId.set(`MNT-${Math.floor(1000 + Math.random() * 9000)}`)
        }, 700)
      }
    
      handleReset(): void {
        clearTimeout(this.submitTimer)
        this.isSubmitted.set(false)
        this.category.set('appliances')
        this.urgency.set('standard')
        this.location.set('kitchen')
        this.description.set(defaultDescription)
        this.permission.set('granted')
        this.attachedFiles.set(defaultFiles.map((f) => ({ ...f })))
      }
    }
    
  • src/app/components/blocks/maintenance-request-ticket/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/maintenance-request-ticket.json