Maintenance Request Ticket
blockreal-estateResident repair and facility work order submission portal with urgency priority and photo dropzone.
Installation
$pnpm dlx uipkge-ng@latest add maintenance-request-ticket$npx uipkge-ng@latest add maintenance-request-ticket$yarn dlx uipkge-ng@latest add maintenance-request-ticket$bunx uipkge-ng@latest add maintenance-request-ticketManifest:
https://uipkge.dev/r/angular/maintenance-request-ticket.jsonInstalls to:src/app/components/blocks/maintenance-request-ticket/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
AttachedFileinterface AttachedFile {
id: string
name: string
size: string
type: 'image' | 'video'
uploadedAt: string
}IssueCategoryinterface IssueCategory {
id: string
label: string
description: string
icon: LucideIconData
badgeColor: string
}UrgencyOptioninterface UrgencyOption {
value: MaintenanceUrgency
label: string
badge: string
badgeVariant: 'outline' | 'secondary' | 'destructive'
description: string
}npm dependencies
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 & 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 & 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 & 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">< 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
export { UiMaintenanceRequestTicketComponent, type AttachedFile, type EntryPermission, type MaintenanceUrgency, } from './maintenance-request-ticket.component'
Raw manifest:https://uipkge.dev/r/angular/maintenance-request-ticket.json