Field Inspection Manager
blocklogisticsMobile/Desktop commercial property & equipment site inspection audit manager featuring GPS geostamp telemetry, 4 KPI metric cards, searchable audits table with pass/fail checklist scores, photo evidence thumbnail gallery with interactive lightbox modal, cryptographic sign-off badges, and work order dispatch workflows.
Installation
$pnpm dlx uipkge-ng@latest add field-inspection-manager$npx uipkge-ng@latest add field-inspection-manager$yarn dlx uipkge-ng@latest add field-inspection-manager$bunx uipkge-ng@latest add field-inspection-managerManifest:
https://uipkge.dev/r/angular/field-inspection-manager.jsonInstalls to:src/app/components/blocks/field-inspection-manager/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.
PhotoEvidenceinterface PhotoEvidence {
id: string
title: string
caption: string
time: string
previewType:
| 'gauge'
| 'coil'
| 'mount'
| 'thermal'
| 'box'
| 'conduit'
| 'riser'
| 'pump'
| 'switch'
| 'tank'
| 'battery'
| 'ats'
| 'spall'
| 'rebar'
| 'crack'
}ChecklistIteminterface ChecklistItem {
id: string
label: string
status: 'pass' | 'fail' | 'minor'
value: string
}InspectionRecordinterface InspectionRecord {
id: string
assetId: string
assetName: string
category: string
facility: string
buildingBadge: string
date: string
time: string
gps: string
gpsAccuracy: string
scorePassed: number
scoreTotal: number
status: InspectionStatus
statusLabel: string
duration: string
defectSummary?: string
workOrder?: string
photos: PhotoEvidence[]
inspector: {
name: string
role: string
initials: string
badge: string
signoffStatus: string
signoffHash: string
}
checklist: ChecklistItem[]
}npm dependencies
Files installed (2)
src/app/components/blocks/field-inspection-manager/field-inspection-manager.component.ts75 kB
// Mobile/Desktop commercial property & equipment site inspection audit manager: // GPS geostamp telemetry, 4 KPI metric cards, searchable audits table with pass/fail // checklist scores, photo evidence thumbnail gallery with interactive lightbox modal, // cryptographic sign-off badges, and work order dispatch workflows. // Port of the React block 1:1 — same five records, same copy, same filters. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { NgTemplateOutlet } from '@angular/common' import { AlertCircle, AlertTriangle, Building2, Calendar, Camera, Check, CheckCircle2, ClipboardCheck, Clock, Copy, Download, Eye, LucideAngularModule, MapPin, MoreHorizontal, Percent, Plus, Search, ShieldAlert, ShieldCheck, UploadCloud, Wrench, } 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, UiCardHeaderComponent } from '@/ui/card/card.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, } from '@/ui/dropdown-menu/dropdown-menu.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export type InspectionStatus = 'passed' | 'critical' | 'minor' export type InspectionFilter = 'all' | 'passed' | 'critical' | 'minor' export interface PhotoEvidence { id: string title: string caption: string time: string previewType: | 'gauge' | 'coil' | 'mount' | 'thermal' | 'box' | 'conduit' | 'riser' | 'pump' | 'switch' | 'tank' | 'battery' | 'ats' | 'spall' | 'rebar' | 'crack' } export interface ChecklistItem { id: string label: string status: 'pass' | 'fail' | 'minor' value: string } export interface InspectionRecord { id: string assetId: string assetName: string category: string facility: string buildingBadge: string date: string time: string gps: string gpsAccuracy: string scorePassed: number scoreTotal: number status: InspectionStatus statusLabel: string duration: string defectSummary?: string workOrder?: string photos: PhotoEvidence[] inspector: { name: string role: string initials: string badge: string signoffStatus: string signoffHash: string } checklist: ChecklistItem[] } const defaultRecords: InspectionRecord[] = [ { id: 'insp-1', assetId: 'AST-HVAC-4091', assetName: 'Commercial HVAC Chiller Unit #4', category: 'HVAC & Thermal Systems', facility: 'Building A · Rooftop Mechanical Room', buildingBadge: 'Bldg A', date: 'Aug 21, 2026', time: '09:15 AM EDT', gps: '34.0522°N, 118.2437°W', gpsAccuracy: '±2.4m', scorePassed: 18, scoreTotal: 18, status: 'passed', statusLabel: '18 / 18 Passed', duration: '26 mins', photos: [ { id: 'p1-1', title: 'Compressor Suction Manifold', caption: 'Suction pressure holding steady at 68.4 PSI; temperature sensor verified within calibration spec.', time: '09:18 AM', previewType: 'gauge', }, { id: 'p1-2', title: 'Condenser Fan Coil Assembly', caption: 'Clean aluminum fin surfaces with zero debris obstruction; axial fan bearings lubricated.', time: '09:24 AM', previewType: 'coil', }, { id: 'p1-3', title: 'Vibration Dampener Isolators', caption: 'Neoprene spring isolator pads intact with zero hairline settling fractures or mounting deflection.', time: '09:32 AM', previewType: 'mount', }, ], inspector: { name: 'Marcus Vance', role: 'Senior Field Engineer', initials: 'MV', badge: 'PE #84920-CA', signoffStatus: 'Verified Cryptographic Sign-off', signoffHash: 'SHA-256: 9f8e21...41a2', }, checklist: [ { id: 'c1-1', label: 'Refrigerant Charge & Pressures', status: 'pass', value: '68.4 PSI Suction / 225 PSI Discharge', }, { id: 'c1-2', label: 'Compressor Motor Amp Draw', status: 'pass', value: '42.1A (rated 46A max)' }, { id: 'c1-3', label: 'Chilled Water Loop Delta-T', status: 'pass', value: '10.2°F Temperature Drop' }, { id: 'c1-4', label: 'Emergency High-Pressure Cutout', status: 'pass', value: 'Trips accurately at 380 PSI' }, ], }, { id: 'insp-2', assetId: 'AST-SOLAR-1082', assetName: 'Rooftop Solar Array & Inverters', category: 'Renewable Energy & Power', facility: 'Building C · South Wing Solar Deck', buildingBadge: 'Bldg C', date: 'Aug 21, 2026', time: '10:45 AM EDT', gps: '34.0528°N, 118.2445°W', gpsAccuracy: '±1.8m', scorePassed: 14, scoreTotal: 18, status: 'critical', statusLabel: '14 / 18 - 2 Critical Fails', duration: '34 mins', defectSummary: 'DC string combiner box busbar overheating (84.2°C); micro-inverter #6 circuit failure.', workOrder: 'WO-SOLAR-4921 (High Priority Dispatched)', photos: [ { id: 'p2-1', title: 'Inverter #6 Thermal Hotspot', caption: 'FLIR radiometric thermography shows 84.2°C localized hotspot at DC-AC bridge capacitor.', time: '10:52 AM', previewType: 'thermal', }, { id: 'p2-2', title: 'Combiner Box Arc Degradation', caption: 'Busbar contact surface oxidation with heat discoloration and slight terminal pitting.', time: '11:04 AM', previewType: 'box', }, { id: 'p2-3', title: 'PV String #4 Conduit Ingress', caption: 'Degraded rubber gasket on string junction conduit fitting allowing moisture penetration.', time: '11:12 AM', previewType: 'conduit', }, ], inspector: { name: 'Marcus Vance', role: 'Senior Field Engineer', initials: 'MV', badge: 'PE #84920-CA', signoffStatus: 'Critical Defect Action Required', signoffHash: 'SHA-256: 4c3d88...71ef', }, checklist: [ { id: 'c2-1', label: 'Combiner Box Busbar Temp', status: 'fail', value: '84.2°C (exceeds 65°C safe operating limit)', }, { id: 'c2-2', label: 'Micro-inverter #6 Communication', status: 'fail', value: 'Modbus timeout / Offline state' }, { id: 'c2-3', label: 'PV Panel Surface Clarity', status: 'pass', value: 'Soiling index < 1.8%' }, { id: 'c2-4', label: 'Rapid Shutdown System', status: 'pass', value: 'Voltages drop < 30V in 12s' }, ], }, { id: 'insp-3', assetId: 'AST-FIRE-8820', assetName: 'Fire Suppression & Sprinkler System', category: 'Life Safety & Protection', facility: 'Main Tower · Sub-Basement Pump Room B2', buildingBadge: 'Tower B2', date: 'Aug 21, 2026', time: '11:30 AM EDT', gps: '34.0515°N, 118.2420°W', gpsAccuracy: '±3.1m', scorePassed: 17, scoreTotal: 18, status: 'minor', statusLabel: '17 / 18 - 1 Minor Defect', duration: '22 mins', defectSummary: 'Main riser pressure gauge annual NIST calibration tag expired by 21 days; pressure intact.', workOrder: 'WO-FIRE-8820 (Standard Calibration Scheduled)', photos: [ { id: 'p3-1', title: 'Main Riser Pressure Gauge', caption: 'Static pressure 145 PSI (nominal). Calibration tag stamped 07/2025 requiring routine re-tagging.', time: '11:34 AM', previewType: 'riser', }, { id: 'p3-2', title: 'Jockey Pump Shaft Packing', caption: 'Packing gland seal dry with zero leakage; auto start/stop pressure cutoff cycle verified.', time: '11:41 AM', previewType: 'pump', }, { id: 'p3-3', title: 'OS&Y Tamper Supervisory Switch', caption: 'Zone 1 control valve open; tamper switch sends instant supervisory signal to FACP.', time: '11:48 AM', previewType: 'switch', }, ], inspector: { name: 'Marcus Vance', role: 'Senior Field Engineer', initials: 'MV', badge: 'PE #84920-CA', signoffStatus: 'Verified Cryptographic Sign-off', signoffHash: 'SHA-256: 7d1a99...33bc', }, checklist: [ { id: 'c3-1', label: 'System Static Water Pressure', status: 'pass', value: '145 PSI static / 125 PSI residual' }, { id: 'c3-2', label: 'Riser Gauge Calibration Tag', status: 'minor', value: 'Overdue by 21 days (recalibration due)', }, { id: 'c3-3', label: 'Diesel Fire Pump Auto-Start', status: 'pass', value: 'Cranked to 1750 RPM in 4.2s' }, { id: 'c3-4', label: 'Flow Alarm Switch Delay', status: 'pass', value: 'Gong chime triggered at 32s' }, ], }, { id: 'insp-4', assetId: 'AST-GEN-3304', assetName: 'Emergency Backup Generator #2', category: 'Emergency Standby Power', facility: 'East Campus · Utility Pad North', buildingBadge: 'East Pad', date: 'Aug 21, 2026', time: '01:15 PM EDT', gps: '34.0535°N, 118.2450°W', gpsAccuracy: '±2.1m', scorePassed: 18, scoreTotal: 18, status: 'passed', statusLabel: '18 / 18 Passed', duration: '29 mins', photos: [ { id: 'p4-1', title: 'Diesel Sub-Base Day Tank', caption: 'Ultra-low sulfur diesel fuel level at 98.4%; optical fuel water separator clean with zero particulate.', time: '01:21 PM', previewType: 'tank', }, { id: 'p4-2', title: '24V Dual Starter Battery Bank', caption: 'Terminal posts clean with dielectric grease coating; float charging verified at 27.6V.', time: '01:28 PM', previewType: 'battery', }, { id: 'p4-3', title: '800A ATS Transfer Switch', caption: 'Phase alignment and mechanical interlocking verified; utility power sync nominal.', time: '01:38 PM', previewType: 'ats', }, ], inspector: { name: 'Marcus Vance', role: 'Senior Field Engineer', initials: 'MV', badge: 'PE #84920-CA', signoffStatus: 'Verified Cryptographic Sign-off', signoffHash: 'SHA-256: 3a7b54...82e9', }, checklist: [ { id: 'c4-1', label: 'Engine Crank & Run Test', status: 'pass', value: '60 Hz output reached in 6.8s' }, { id: 'c4-2', label: 'Coolant Jacket Heater Temp', status: 'pass', value: '118°F Block temperature' }, { id: 'c4-3', label: 'Oil Pressure & Level', status: 'pass', value: '55 PSI running oil pressure' }, { id: 'c4-4', label: 'Exhaust Silencer & Insulation', status: 'pass', value: 'Zero exhaust gas or soot leakage' }, ], }, { id: 'insp-5', assetId: 'AST-STR-0019', assetName: 'Structural Foundation & Load Beams', category: 'Structural Integrity & Civil', facility: 'Parking Structure · Level P3 Pillar Grid 4D', buildingBadge: 'P3 Grid 4D', date: 'Aug 21, 2026', time: '02:40 PM EDT', gps: '34.0510°N, 118.2412°W', gpsAccuracy: '±1.9m', scorePassed: 16, scoreTotal: 18, status: 'critical', statusLabel: '16 / 18 - 2 Critical Fails', duration: '31 mins', defectSummary: 'Concrete shear spall at beam joint 4D corbel; exposed tension rebar #6 exhibiting active oxidation.', workOrder: 'WO-STR-4922 (Structural Engineer On-Site)', photos: [ { id: 'p5-1', title: 'Pillar 4D Beam Shear Spall', caption: 'Concrete shear delamination (18cm wide x 4cm depth) near primary corbel support bracket.', time: '02:46 PM', previewType: 'spall', }, { id: 'p5-2', title: 'Exposed Steel Rebar Oxidation', caption: 'Deformed rebar #6 exposed to ambient moisture with active ferric surface oxidation.', time: '02:54 PM', previewType: 'rebar', }, { id: 'p5-3', title: 'Optical Crack Comparator Test', caption: 'Structural hairline fissure width measured at 2.2mm across tension face (spec max 0.3mm).', time: '03:02 PM', previewType: 'crack', }, ], inspector: { name: 'Marcus Vance', role: 'Senior Field Engineer', initials: 'MV', badge: 'PE #84920-CA', signoffStatus: 'Critical Defect Action Required', signoffHash: 'SHA-256: 8e5f12...04db', }, checklist: [ { id: 'c5-1', label: 'Concrete Cover Spalling', status: 'fail', value: '18cm spall at corbel connection' }, { id: 'c5-2', label: 'Structural Crack Aperture', status: 'fail', value: '2.2mm fissure (exceeds 0.3mm tolerance)', }, { id: 'c5-3', label: 'Expansion Joint Elastomer', status: 'pass', value: 'Sealant elasticity within spec' }, { id: 'c5-4', label: 'Drainage Channel Clearance', status: 'pass', value: 'Deck scuppers free of sediment' }, ], }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-field-inspection-manager, [ui-field-inspection-manager]', 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, NgTemplateOutlet, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardHeaderComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div data-slot="field-inspection-manager" [class]="rootClass"> <!-- TOAST NOTIFICATION BANNER --> @if (toastMessage()) { <div class="border-primary/30 bg-primary/10 text-foreground fixed top-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-4 py-2.5 text-xs font-medium shadow-lg backdrop-blur-md" > <lucide-icon [img]="CheckCircle2" class="text-primary size-4 shrink-0" /> <span>{{ toastMessage() }}</span> </div> } <!-- MAIN HEADER: TITLE, INSPECTOR BADGE, ON-TIME STATUS, ACTION CTA --> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-col gap-4 pb-6 lg:flex-row lg:items-start lg:justify-between"> <div class="space-y-2"> <!-- Compliance Pills & Live Status --> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="outline" class="gap-1.5 font-mono text-xs"> <lucide-icon [img]="ClipboardCheck" class="text-primary size-3.5" aria-hidden="true" /> ISO-55001 & OSHA Audit Matrix </span> <!-- Emerald Status Badge --> <div class="border-success/20 bg-success/10 text-success inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium" > <span class="relative flex size-1.5"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-1.5 rounded-full"></span> </span> 8 Audits Completed Today · 100% On-Time </div> </div> <!-- Title & Inspector Subtext --> <div> <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl"> Site & Asset Inspection Audits </h1> <div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs sm:text-sm"> <div class="text-foreground flex items-center gap-1.5 font-medium"> <ui-avatar class="border-border size-5 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-bold">MV</ui-avatar-fallback> </ui-avatar> <span>Marcus Vance</span> </div> <span class="text-muted-foreground">Senior Field Engineer (PE #84920-CA)</span> <span class="text-muted-foreground font-mono">Terminal: Apex Facility West</span> </div> </div> </div> <!-- Action CTAs --> <div class="flex flex-wrap items-center gap-2.5 pt-1"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="gap-1.5 text-xs font-medium shadow-xs" (click)="showToast('Exporting complete site inspection audit summary CSV...')" > <lucide-icon [img]="Download" class="size-3.5" aria-hidden="true" /> Export Audits CSV </button> <button ui-button size="sm" class="gap-1.5 text-xs font-medium shadow-xs" (click)="handleStartNewInspection()" > <lucide-icon [img]="Plus" class="size-3.5" aria-hidden="true" /> Start New Inspection </button> </div> </div> </div> <!-- 4 FIELD INSPECTION METRIC KPI CARDS --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Completed Inspections</span> <div class="border-success/20 bg-success/10 text-success flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="CheckCircle2" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">24</div> <div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs"> <span class="text-success font-medium">24 Audits This Week</span> <span>·</span> <span class="text-muted-foreground">+14.2% vs target</span> </div> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Critical Defects Flagged</span> <div class="border-destructive/20 bg-destructive/10 text-destructive flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="ShieldAlert" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">2</div> <div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs"> <span class="text-destructive font-semibold">2 Critical Deficiencies</span> <span>·</span> <span class="text-muted-foreground">Work Orders Dispatched</span> </div> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">First-Time Pass Rate</span> <div class="border-primary/20 bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="Percent" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">91.6%</div> <div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs"> <span class="text-success font-medium">91.6% Pass Rate</span> <span>·</span> <span class="text-muted-foreground">Above 90.0% SLA</span> </div> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Avg Inspection Duration</span> <div class="border-warning/20 bg-warning/10 text-warning flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="Clock" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3"> <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">28m</div> <div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs"> <span class="text-foreground font-medium">28 mins / audit</span> <span>·</span> <span class="text-success">-4m vs benchmark</span> </div> </div> </div> </div> </div> <!-- SEARCH & FILTER TOOLBAR --> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="p-4"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <!-- Filter Tabs / Pills --> <div class="flex flex-wrap items-center gap-1.5"> <button type="button" [class]="filterPillClass('all')" (click)="activeFilter.set('all')"> All Audits (5) </button> <button type="button" [class]="filterPillClass('passed')" (click)="activeFilter.set('passed')"> <lucide-icon [img]="CheckCircle2" class="size-3.5" /> Passed (2) </button> <button type="button" [class]="filterPillClass('critical')" (click)="activeFilter.set('critical')"> <lucide-icon [img]="AlertTriangle" class="size-3.5" /> Critical Defects (2) </button> <button type="button" [class]="filterPillClass('minor')" (click)="activeFilter.set('minor')"> <lucide-icon [img]="AlertCircle" class="size-3.5" /> Minor Issues (1) </button> </div> <!-- Search Input & Facility Selector --> <div class="flex flex-wrap items-center gap-2"> <div class="w-full sm:w-48"> <ui-select [value]="selectedFacility()" (valueChange)="setSelectedFacility($event)"> <button ui-select-trigger class="h-8 text-xs"> <ui-select-value placeholder="All Facilities" /> </button> <ui-select-content> <ui-select-item value="all">All Facilities (5)</ui-select-item> <ui-select-item value="Building A">Building A (2)</ui-select-item> <ui-select-item value="Building B">Building B (1)</ui-select-item> <ui-select-item value="Building C">Building C (1)</ui-select-item> <ui-select-item value="Parking Structure">Parking Structure (1)</ui-select-item> </ui-select-content> </ui-select> </div> <div class="relative w-full sm:w-64"> <lucide-icon [img]="Search" class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <ui-input [value]="searchQuery()" (valueChange)="searchQuery.set($event)" type="text" placeholder="Search asset, ID, category..." class="h-8 pl-8 text-xs" /> </div> </div> </div> </div> </div> <!-- AUDIT RECORDS DATA TABLE --> <div ui-card class="border-border overflow-hidden shadow-xs"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/30"> <th ui-table-head class="text-foreground w-[220px] text-xs font-semibold">Asset & Facility</th> <th ui-table-head class="text-foreground text-xs font-semibold">Date & GPS Stamping</th> <th ui-table-head class="text-foreground text-xs font-semibold">Inspection Checklist</th> <th ui-table-head class="text-foreground text-xs font-semibold">Status & Work Order</th> <th ui-table-head class="text-foreground text-xs font-semibold">Photographic Evidence</th> <th ui-table-head class="text-foreground text-xs font-semibold">Inspector & Sign-off</th> <th ui-table-head class="text-foreground text-right text-xs font-semibold">Actions</th> </tr> </thead> <tbody ui-table-body> @for (record of filteredRecords(); track record.id) { <tr ui-table-row class="border-border hover:bg-muted/20 transition-colors"> <!-- 1. Asset & Facility --> <td ui-table-cell class="py-3.5 align-top"> <div class="space-y-1"> <div class="flex items-center gap-1.5"> <span class="text-foreground text-xs font-bold">{{ record.assetName }}</span> <span ui-badge variant="outline" class="font-mono text-xs">{{ record.buildingBadge }}</span> </div> <div class="text-muted-foreground font-mono text-xs">{{ record.assetId }}</div> <div class="text-muted-foreground/90 flex items-center gap-1 text-xs"> <lucide-icon [img]="Building2" class="size-3 shrink-0" /> <span class="truncate">{{ record.facility }}</span> </div> </div> </td> <!-- 2. Date & GPS Stamping --> <td ui-table-cell class="py-3.5 align-top"> <div class="space-y-1"> <div class="text-foreground flex items-center gap-1 text-xs font-medium"> <lucide-icon [img]="Calendar" class="text-muted-foreground size-3" /> <span>{{ record.date }}</span> </div> <div class="text-muted-foreground flex items-center gap-1 font-mono text-xs"> <lucide-icon [img]="Clock" class="size-3" /> <span>{{ record.time }} ({{ record.duration }})</span> </div> <button type="button" [attr.aria-label]="'Copy GPS coordinates for ' + record.assetId + ': ' + record.gps" class="border-border hover:bg-muted focus-visible:ring-ring bg-muted/40 text-muted-foreground inline-flex cursor-pointer items-center gap-1 rounded border px-2 py-0.5 font-mono text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" (click)="copyGps(record.gps, record.id)" > <lucide-icon [img]="MapPin" class="text-primary size-2.5" /> <span>{{ record.gps }}</span> @if (copiedGpsId() === record.id) { <lucide-icon [img]="Check" class="text-success size-2.5" /> } @else { <lucide-icon [img]="Copy" class="text-muted-foreground/60 size-2.5" /> } </button> </div> </td> <!-- 3. Inspection Checklist --> <td ui-table-cell class="py-3.5 align-top"> <div class="w-48 space-y-1.5"> <div class="flex items-center justify-between text-xs"> <span class="text-muted-foreground font-medium">Score</span> <span [class]="scoreClass(record.status)">{{ record.statusLabel }}</span> </div> <div class="bg-muted h-1.5 w-full overflow-hidden rounded-full"> <div [class]="scoreBarClass(record.status)" [style.width]="(record.scorePassed / record.scoreTotal) * 100 + '%'" ></div> </div> <div class="text-muted-foreground space-y-0.5 text-xs"> @for (item of record.checklist.slice(0, 2); track item.id) { <div class="flex items-center gap-1 truncate font-mono text-xs"> @if (item.status === 'pass') { <lucide-icon [img]="CheckCircle2" class="text-success size-2.5 shrink-0" /> } @else if (item.status === 'fail') { <lucide-icon [img]="AlertTriangle" class="text-destructive size-2.5 shrink-0" /> } @else { <lucide-icon [img]="AlertCircle" class="text-warning size-2.5 shrink-0" /> } <span class="truncate">{{ item.label }}: {{ item.value }}</span> </div> } </div> </div> </td> <!-- 4. Status & Work Order --> <td ui-table-cell class="py-3.5 align-top"> <div class="space-y-1.5"> <span ui-badge [variant]="record.status === 'passed' ? 'default' : 'destructive'" [class]="statusBadgeClass(record.status)" > @if (record.status === 'passed') { <lucide-icon [img]="CheckCircle2" class="mr-1 size-3" /> } @else if (record.status === 'critical') { <lucide-icon [img]="AlertTriangle" class="mr-1 size-3" /> } @else { <lucide-icon [img]="AlertCircle" class="mr-1 size-3" /> } {{ record.status }} </span> @if (record.defectSummary) { <p class="text-destructive max-w-[200px] text-xs leading-tight font-medium"> {{ record.defectSummary }} </p> } @else { <p class="text-muted-foreground text-xs leading-tight"> All calibration points verified within tolerance. </p> } @if (record.workOrder) { <div class="pt-0.5"> <span class="border-border bg-muted/30 text-muted-foreground inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-xs" > <lucide-icon [img]="Wrench" class="text-warning size-2.5" /> <span class="max-w-[140px] truncate">{{ record.workOrder }}</span> </span> </div> } </div> </td> <!-- 5. Photographic Evidence --> <td ui-table-cell class="py-3.5 align-top"> <div class="space-y-1.5"> <div class="flex items-center gap-1.5"> @for (photo of record.photos; track photo.id; let pIdx = $index) { <button type="button" [attr.aria-label]="'View inspection photo ' + (pIdx + 1) + ': ' + photo.title" class="border-border hover:border-primary focus-visible:ring-ring group/thumb relative size-11 shrink-0 cursor-pointer overflow-hidden rounded-md border bg-zinc-900 shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" (click)="handleOpenPhoto(photo, record)" > <ng-container *ngTemplateOutlet="thumbnail; context: { $implicit: photo.previewType }" /> <div class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover/thumb:opacity-100" > <lucide-icon [img]="Eye" class="size-3.5 text-white" /> </div> </button> } </div> <div class="text-muted-foreground flex items-center gap-1 text-xs"> <lucide-icon [img]="Camera" class="size-3" /> <span>{{ record.photos.length }} Attachments</span> </div> </div> </td> <!-- 6. Inspector & Sign-off --> <td ui-table-cell class="py-3.5 align-top"> <div class="space-y-1"> <div class="flex items-center gap-2"> <ui-avatar class="border-border size-6 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-bold"> {{ record.inspector.initials }} </ui-avatar-fallback> </ui-avatar> <span class="text-foreground text-xs font-semibold">{{ record.inspector.name }}</span> </div> <div> <span [class]="signoffClass(record.status)"> @if (record.status !== 'critical') { <lucide-icon [img]="ShieldCheck" class="size-3 shrink-0" /> } @else { <lucide-icon [img]="ShieldAlert" class="size-3 shrink-0" /> } {{ record.inspector.signoffStatus }} </span> </div> <div class="text-muted-foreground/80 font-mono text-xs">{{ record.inspector.signoffHash }}</div> </div> </td> <!-- 7. Actions Dropdown --> <td ui-table-cell class="py-3.5 text-right align-top"> <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="sm" class="size-8 p-0"> <span class="sr-only">Open audit menu</span> <lucide-icon [img]="MoreHorizontal" class="size-4" /> </button> <ui-dropdown-menu-content align="end" class="w-48 text-xs"> <div ui-dropdown-menu-label class="font-mono text-xs">{{ record.assetId }}</div> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item class="cursor-pointer gap-2" (select)="handleViewReport(record)"> <lucide-icon [img]="Eye" class="text-primary size-3.5" /> <span>View Audit Report</span> </ui-dropdown-menu-item> <ui-dropdown-menu-item class="cursor-pointer gap-2" (select)="handleDownloadPdf(record)"> <lucide-icon [img]="Download" class="size-3.5" /> <span>Download PDF</span> </ui-dropdown-menu-item> <ui-dropdown-menu-item class="cursor-pointer gap-2" (select)="copyGps(record.gps, record.id)"> <lucide-icon [img]="Copy" class="size-3.5" /> <span>Copy GPS Coordinates</span> </ui-dropdown-menu-item> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item class="cursor-pointer gap-2" [class.text-destructive]="record.status === 'critical'" [class.font-semibold]="record.status === 'critical'" (select)="handleCreateWorkOrder(record)" > <lucide-icon [img]="Wrench" class="size-3.5" /> <span>{{ record.status === 'critical' ? 'Expedite Work Order' : 'Create Work Order' }}</span> </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </td> </tr> } </tbody> </table> </div> </div> <!-- PHOTO EVIDENCE LIGHTBOX MODAL DIALOG --> <ui-dialog [open]="!!previewPhoto()" (openChange)="onPreviewOpenChange($event)"> @if (previewPhoto(); as preview) { <ui-dialog-content class="sm:max-w-2xl"> <div ui-dialog-header> <div class="flex items-center justify-between pr-4"> <div class="flex items-center gap-2"> <lucide-icon [img]="Camera" class="text-primary size-4" /> <h2 ui-dialog-title class="text-base font-bold">{{ preview.photo.title }}</h2> </div> <span ui-badge variant="outline" class="font-mono text-xs">{{ preview.record.assetId }}</span> </div> <p ui-dialog-description class="text-xs"> Captured during {{ preview.record.assetName }} site inspection audit. </p> </div> <div class="space-y-4 py-2"> <div class="border-border relative aspect-[16/10] w-full overflow-hidden rounded-lg border bg-gradient-to-br from-zinc-800 via-zinc-900 to-black p-4 text-white shadow-inner" > <div class="absolute inset-0 flex items-center justify-center p-6"> @if (preview.photo.previewType === 'gauge') { <svg class="h-full w-full" viewBox="0 0 300 200" fill="none"> <rect width="300" height="200" fill="#18181b" /> <circle cx="150" cy="100" r="70" fill="#27272a" stroke="#71717a" stroke-width="4" /> <path d="M 100,125 A 55,55 0 1,1 200,125" fill="none" stroke="#22c55e" stroke-width="6" stroke-dasharray="4 2" /> <line x1="150" y1="100" x2="185" y2="65" stroke="#ef4444" stroke-width="3.5" stroke-linecap="round" /> <circle cx="150" cy="100" r="8" fill="#f4f4f5" /> <text x="150" y="140" fill="#a1a1aa" font-size="12" text-anchor="middle" font-family="monospace"> 68.4 PSI · NOMINAL </text> </svg> } @else if (preview.photo.previewType === 'thermal') { <svg class="h-full w-full" viewBox="0 0 300 200" fill="none"> <rect width="300" height="200" fill="#3b0764" /> <circle cx="150" cy="100" r="65" fill="#db2777" fill-opacity="0.6" /> <circle cx="150" cy="100" r="30" fill="#facc15" /> <line x1="80" y1="100" x2="220" y2="100" stroke="#ffffff" stroke-width="1.5" /> <line x1="150" y1="30" x2="150" y2="170" stroke="#ffffff" stroke-width="1.5" /> <circle cx="150" cy="100" r="6" stroke="#ffffff" stroke-width="1.5" fill="none" /> <text x="160" y="90" fill="#ffffff" font-size="14" font-weight="bold" font-family="monospace"> 84.2°C CRITICAL </text> </svg> } @else if (preview.photo.previewType === 'spall') { <svg class="h-full w-full" viewBox="0 0 300 200" fill="none"> <rect width="300" height="200" fill="#27272a" /> <polygon points="40,30 260,30 260,170 170,170 130,110 40,100" fill="#52525b" stroke="#71717a" stroke-width="2" /> <polygon points="130,110 180,90 170,170" fill="#ef4444" fill-opacity="0.4" stroke="#ef4444" stroke-width="2" /> <line x1="130" y1="110" x2="180" y2="90" stroke="#facc15" stroke-width="2" stroke-dasharray="3 3" /> <text x="155" y="80" fill="#facc15" font-size="12" font-family="monospace">18cm SHEAR SPALL</text> </svg> } @else { <svg class="h-full w-full" viewBox="0 0 300 200" fill="none"> <rect width="300" height="200" fill="#18181b" /> <rect x="40" y="30" width="220" height="140" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="2" /> <circle cx="150" cy="100" r="35" fill="#3f3f46" stroke="#38bdf8" stroke-width="2" /> <line x1="70" y1="60" x2="230" y2="60" stroke="#38bdf8" stroke-width="1.5" /> <line x1="70" y1="140" x2="230" y2="140" stroke="#38bdf8" stroke-width="1.5" /> </svg> } </div> <div class="pointer-events-none absolute inset-4 border border-white/20"> <div class="border-success absolute -top-1 -left-1 size-3 border-t-2 border-l-2"></div> <div class="border-success absolute -top-1 -right-1 size-3 border-t-2 border-r-2"></div> <div class="border-success absolute -bottom-1 -left-1 size-3 border-b-2 border-l-2"></div> <div class="border-success absolute -right-1 -bottom-1 size-3 border-r-2 border-b-2"></div> </div> <div class="absolute top-3 left-3 flex items-center gap-1.5 rounded bg-black/70 px-2 py-1 font-mono text-xs backdrop-blur-sm" > <span class="bg-success size-2 animate-pulse rounded-full"></span> <span>GEO-AUTHENTICATED FIELD PHOTO</span> </div> <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-3 pt-6 font-mono text-xs" > <div class="flex flex-wrap items-center justify-between gap-2"> <span class="text-success font-bold tabular-nums"> 📍 {{ preview.record.gps }} ({{ preview.record.gpsAccuracy }}) </span> <span class="text-zinc-300">{{ preview.record.date }} · {{ preview.photo.time }}</span> </div> </div> </div> <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3 text-xs"> <div class="text-foreground font-semibold">Inspector Field Observation:</div> <p class="text-muted-foreground leading-relaxed">{{ preview.photo.caption }}</p> </div> <div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4"> <div class="border-border bg-muted/30 rounded border p-2"> <span class="text-muted-foreground block text-xs">Asset ID</span> <span class="text-foreground font-mono font-semibold">{{ preview.record.assetId }}</span> </div> <div class="border-border bg-muted/30 rounded border p-2"> <span class="text-muted-foreground block text-xs">Facility</span> <span class="text-foreground truncate font-medium">{{ preview.record.buildingBadge }}</span> </div> <div class="border-border bg-muted/30 rounded border p-2"> <span class="text-muted-foreground block text-xs">Capture Timestamp</span> <span class="text-foreground font-mono font-medium tabular-nums">{{ preview.photo.time }}</span> </div> <div class="border-border bg-muted/30 rounded border p-2"> <span class="text-muted-foreground block text-xs">Cryptographic Seal</span> <span class="text-success font-mono font-medium">Valid SHA-256</span> </div> </div> </div> <div ui-dialog-footer class="flex flex-wrap items-center justify-between gap-2"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs font-medium" (click)="showToast('Exporting high-resolution raw image file...')" > <lucide-icon [img]="Download" class="mr-1.5 size-3.5" /> Download Original Photo </button> <button ui-button size="sm" class="text-xs" (click)="previewPhoto.set(null)">Close</button> </div> </ui-dialog-content> } </ui-dialog> <!-- COMPREHENSIVE AUDIT REPORT MODAL DIALOG --> <ui-dialog [open]="!!selectedAudit()" (openChange)="onAuditOpenChange($event)"> @if (selectedAudit(); as audit) { <ui-dialog-content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl"> <div ui-dialog-header> <div class="flex flex-wrap items-center justify-between gap-2 pr-4"> <div class="space-y-1"> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="font-mono text-xs">{{ audit.assetId }}</span> <span ui-badge [variant]=" audit.status === 'critical' ? 'destructive' : audit.status === 'minor' ? 'warning' : 'outline' " [class]=" audit.status === 'passed' ? 'border-success/30 bg-success/10 text-success font-mono text-xs font-semibold' : 'font-mono text-xs font-semibold' " > {{ audit.statusLabel }} </span> </div> <h2 ui-dialog-title class="text-lg font-bold sm:text-xl">{{ audit.assetName }}</h2> </div> </div> <p ui-dialog-description class="text-xs"> Commercial facility asset engineering inspection audit record · {{ audit.category }} </p> </div> <div class="space-y-5 py-2"> <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div class="border-border bg-muted/20 rounded-lg border p-3"> <span class="text-muted-foreground block text-xs font-medium">Facility Location</span> <span class="text-foreground mt-0.5 block text-xs font-semibold">{{ audit.facility }}</span> </div> <div class="border-border bg-muted/20 rounded-lg border p-3"> <span class="text-muted-foreground block text-xs font-medium">Inspection Time</span> <span class="text-foreground mt-0.5 block text-xs font-semibold tabular-nums"> {{ audit.date }} · {{ audit.time }} </span> </div> <div class="border-border bg-muted/20 rounded-lg border p-3"> <span class="text-muted-foreground block text-xs font-medium">GPS Geostamp</span> <span class="text-foreground mt-0.5 block font-mono text-xs font-semibold tabular-nums"> {{ audit.gps }} </span> <span class="text-muted-foreground text-xs">Accuracy: {{ audit.gpsAccuracy }}</span> </div> <div class="border-border bg-muted/20 rounded-lg border p-3"> <span class="text-muted-foreground block text-xs font-medium">Audit Duration</span> <span class="text-foreground mt-0.5 block text-xs font-semibold tabular-nums"> {{ audit.duration }} </span> <span class="text-muted-foreground text-xs">SLA compliant</span> </div> </div> @if (audit.status === 'critical') { <div class="border-destructive/30 bg-destructive/10 text-destructive space-y-1.5 rounded-lg border p-3.5 text-xs" > <div class="flex items-center gap-1.5 font-bold"> <lucide-icon [img]="AlertTriangle" class="text-destructive size-4" /> <span>Critical Deficiencies Flagged · Action Dispatched</span> </div> <p class="leading-relaxed">{{ audit.defectSummary }}</p> <div class="pt-1 font-mono font-semibold">Active Work Order: {{ audit.workOrder }}</div> </div> } <div class="space-y-2"> <div class="flex items-center justify-between"> <span class="text-foreground text-xs font-semibold">Pass / Fail Checklist Audit Items</span> <span class="text-muted-foreground font-mono text-xs tabular-nums"> {{ audit.scorePassed }} / {{ audit.scoreTotal }} Passed </span> </div> <div class="border-border divide-border divide-y rounded-lg border"> @for (item of audit.checklist; track item.id) { <div class="flex items-center justify-between p-2.5 text-xs"> <div class="flex items-center gap-2"> <span ui-badge [variant]=" item.status === 'fail' ? 'destructive' : item.status === 'minor' ? 'warning' : 'outline' " class="h-5 px-1.5 font-mono text-xs uppercase" > {{ item.status }} </span> <span class="text-foreground font-medium">{{ item.label }}</span> </div> <span class="text-muted-foreground font-mono text-xs tabular-nums">{{ item.value }}</span> </div> } </div> </div> <div class="space-y-2"> <span class="text-foreground text-xs font-semibold">Photographic Evidence Attachments</span> <div class="grid grid-cols-3 gap-3"> @for (photo of audit.photos; track photo.id) { <button type="button" class="border-border hover:border-primary/60 group/p cursor-pointer overflow-hidden rounded-lg border bg-zinc-900 p-2 text-left transition-colors" (click)="handleOpenPhoto(photo, audit)" > <div class="relative aspect-[16/10] w-full overflow-hidden rounded bg-black"> <div class="absolute inset-0 flex items-center justify-center"> <lucide-icon [img]="Camera" class="text-muted-foreground size-6" /> </div> </div> <div class="text-foreground mt-1.5 truncate text-xs font-semibold">{{ photo.title }}</div> <div class="text-muted-foreground font-mono text-xs tabular-nums">{{ photo.time }}</div> </button> } </div> </div> <div class="border-border bg-muted/15 space-y-2 rounded-xl border p-4"> <div class="flex items-center justify-between"> <span class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium"> <lucide-icon [img]="ShieldCheck" class="text-primary size-3.5" /> Electronic Cryptographic Engineer Sign-off </span> <span class="text-muted-foreground font-mono text-xs">{{ audit.inspector.badge }}</span> </div> <div class="border-border/60 border-b pt-1 pb-3"> <p class="text-primary text-2xl font-medium tracking-wide italic">{{ audit.inspector.name }}</p> </div> <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-2 font-mono text-xs"> <span>Signer: {{ audit.inspector.name }} · {{ audit.inspector.role }}</span> <span>{{ audit.inspector.signoffHash }}</span> </div> </div> </div> <div ui-dialog-footer class="flex flex-wrap items-center justify-between gap-2"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs font-medium" (click)="handleDownloadPdf(audit)" > <lucide-icon [img]="Download" class="mr-1.5 size-3.5" /> Download PDF Report </button> <button ui-button size="sm" class="text-xs" (click)="selectedAudit.set(null)">Close</button> </div> </ui-dialog-content> } </ui-dialog> <!-- START NEW INSPECTION MODAL DIALOG --> <ui-dialog [open]="isNewAuditOpen()" (openChange)="isNewAuditOpen.set($event)"> <ui-dialog-content class="sm:max-w-xl"> <div ui-dialog-header> <div class="flex items-center gap-2"> <lucide-icon [img]="ClipboardCheck" class="text-primary size-5" /> <h2 ui-dialog-title class="text-base font-bold">Start New Site & Asset Inspection Audit</h2> </div> <p ui-dialog-description class="text-xs"> Initiate a field engineering inspection with automatic GPS geostamping and checklist validation. </p> </div> @if (newAuditSuccess()) { <div class="space-y-3 py-8 text-center"> <div class="bg-success/20 text-success mx-auto flex size-12 items-center justify-center rounded-full"> <lucide-icon [img]="CheckCircle2" class="size-6" /> </div> <h3 class="text-foreground text-base font-bold">Inspection Audit Logged Successfully</h3> <p class="text-muted-foreground mx-auto max-w-sm text-xs"> Audit has been recorded with GPS geostamp 34.0522°N, 118.2437°W and synced to cloud repository. </p> </div> } @else { <div class="space-y-4 py-2"> <div class="grid gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label class="text-foreground text-xs font-semibold">Select Target Asset *</label> <ui-select [value]="newAuditAsset()" (valueChange)="newAuditAsset.set($event)"> <button ui-select-trigger class="h-8 text-xs"> <ui-select-value /> </button> <ui-select-content> <ui-select-item value="AST-HVAC-4091">AST-HVAC-4091 · Chiller Unit #4</ui-select-item> <ui-select-item value="AST-SOLAR-1082">AST-SOLAR-1082 · Solar Array & Inverters</ui-select-item> <ui-select-item value="AST-FIRE-8820">AST-FIRE-8820 · Fire Sprinkler System</ui-select-item> <ui-select-item value="AST-GEN-3304">AST-GEN-3304 · Backup Generator #2</ui-select-item> <ui-select-item value="AST-STR-0019">AST-STR-0019 · Foundation & Beams</ui-select-item> <ui-select-item value="AST-ELEV-9012">AST-ELEV-9012 · Passenger Elevator Bank A</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label class="text-foreground text-xs font-semibold">Facility Location</label> <ui-input [value]="newAuditFacility()" (valueChange)="newAuditFacility.set($event)" class="h-8 text-xs" /> </div> </div> <div class="border-border bg-muted/20 flex items-center justify-between rounded-lg border p-3 text-xs"> <div class="flex items-center gap-2"> <lucide-icon [img]="MapPin" class="text-success size-4 shrink-0" /> <div> <span class="text-foreground font-semibold">Live GPS Telemetry Lock</span> <span class="text-muted-foreground block font-mono text-xs"> 34.0522°N, 118.2437°W · Accuracy ±1.5m </span> </div> </div> <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success font-mono text-xs"> GPS Locked </span> </div> <div class="space-y-1.5"> <label class="text-foreground text-xs font-semibold">Photographic Evidence</label> <div class="border-border/80 hover:border-primary/50 bg-card cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors" (click)="showToast('Mock photo uploaded: asset_evidence_live.jpg')" > <div class="flex flex-col items-center justify-center gap-1"> <lucide-icon [img]="UploadCloud" class="text-primary size-5" /> <p class="text-foreground text-xs font-medium">Click to upload photo evidence or drag & drop</p> <p class="text-muted-foreground text-xs">Supports JPG, PNG up to 15MB with GPS EXIF retention</p> </div> </div> </div> <div class="space-y-1.5"> <label class="text-foreground text-xs font-semibold">Inspector Observations & Checklist Notes</label> <ui-input [value]="newAuditNotes()" (valueChange)="newAuditNotes.set($event)" placeholder="Enter notes, gauge pressures, or deficiency remarks..." class="h-8 text-xs" /> </div> </div> } @if (!newAuditSuccess()) { <div ui-dialog-footer class="flex items-center justify-end gap-2"> <button ui-button variant="outline" size="sm" class="text-xs" (click)="isNewAuditOpen.set(false)"> Cancel </button> <button ui-button size="sm" class="gap-1.5 text-xs font-medium" (click)="submitNewAudit()"> <lucide-icon [img]="ClipboardCheck" class="size-3.5" /> Submit Completed Audit </button> </div> } </ui-dialog-content> </ui-dialog> </div> <!-- Vector SVG thumbnail per photo preview type (themed, no baked screenshots). --> <ng-template #thumbnail let-type> <div class="h-full w-full"> @if (type === 'gauge') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <circle cx="22" cy="22" r="19" fill="#18181b" stroke="#3f3f46" stroke-width="1.5" /> <path d="M 12,28 A 14,14 0 1,1 32,28" fill="none" stroke="#22c55e" stroke-width="2" stroke-dasharray="2 2" /> <line x1="22" y1="22" x2="28" y2="15" stroke="#ef4444" stroke-width="1.5" stroke-linecap="round" /> <circle cx="22" cy="22" r="2.5" fill="#f4f4f5" /> </svg> } @else if (type === 'coil') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <line x1="6" y1="10" x2="38" y2="10" stroke="#38bdf8" stroke-width="1.5" /> <line x1="6" y1="18" x2="38" y2="18" stroke="#38bdf8" stroke-width="1.5" /> <line x1="6" y1="26" x2="38" y2="26" stroke="#38bdf8" stroke-width="1.5" /> <line x1="6" y1="34" x2="38" y2="34" stroke="#38bdf8" stroke-width="1.5" /> <circle cx="22" cy="22" r="7" fill="#0284c7" fill-opacity="0.3" stroke="#38bdf8" stroke-width="1.5" /> </svg> } @else if (type === 'mount') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="8" y="8" width="28" height="4" rx="1" fill="#71717a" /> <path d="M 14,12 C 14,18 30,18 30,24 C 30,30 14,30 14,36" fill="none" stroke="#f59e0b" stroke-width="2" /> <rect x="8" y="36" width="28" height="4" rx="1" fill="#71717a" /> </svg> } @else if (type === 'thermal') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#3b0764" /> <circle cx="22" cy="22" r="14" fill="#db2777" fill-opacity="0.6" /> <circle cx="22" cy="22" r="7" fill="#facc15" /> <line x1="12" y1="22" x2="32" y2="22" stroke="#ffffff" stroke-width="0.75" /> <line x1="22" y1="12" x2="22" y2="32" stroke="#ffffff" stroke-width="0.75" /> </svg> } @else if (type === 'box') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="6" y="6" width="32" height="32" rx="2" stroke="#eab308" stroke-width="1.5" /> <line x1="14" y1="12" x2="14" y2="32" stroke="#f97316" stroke-width="2" /> <line x1="22" y1="12" x2="22" y2="32" stroke="#f97316" stroke-width="2" /> <circle cx="22" cy="22" r="5" fill="#ef4444" /> </svg> } @else if (type === 'conduit') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="16" y="4" width="12" height="36" fill="#52525b" /> <circle cx="22" cy="22" r="10" fill="#a1a1aa" fill-opacity="0.3" stroke="#e4e4e7" stroke-width="1.5" /> <line x1="16" y1="22" x2="28" y2="22" stroke="#ef4444" stroke-width="1.5" /> </svg> } @else if (type === 'riser') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="14" y="4" width="16" height="36" fill="#dc2626" /> <circle cx="22" cy="18" r="8" fill="#e4e4e7" stroke="#dc2626" stroke-width="1.5" /> <line x1="22" y1="18" x2="26" y2="14" stroke="#000000" stroke-width="1" /> </svg> } @else if (type === 'pump') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <circle cx="22" cy="22" r="13" fill="#b91c1c" stroke="#f87171" stroke-width="1.5" /> <circle cx="22" cy="22" r="5" fill="#52525b" /> <line x1="22" y1="4" x2="22" y2="10" stroke="#f87171" stroke-width="2" /> </svg> } @else if (type === 'switch') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <circle cx="22" cy="16" r="9" stroke="#dc2626" stroke-width="2" fill="none" /> <rect x="16" y="24" width="12" height="14" rx="2" fill="#eab308" /> </svg> } @else if (type === 'tank') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="10" y="8" width="24" height="28" rx="6" fill="#3f3f46" stroke="#71717a" stroke-width="1.5" /> <rect x="14" y="14" width="4" height="16" rx="1" fill="#22c55e" /> </svg> } @else if (type === 'battery') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="6" y="12" width="32" height="24" rx="2" fill="#27272a" stroke="#52525b" stroke-width="1.5" /> <rect x="11" y="8" width="6" height="4" fill="#ef4444" /> <rect x="27" y="8" width="6" height="4" fill="#3b82f6" /> </svg> } @else if (type === 'ats') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <rect x="6" y="6" width="32" height="32" rx="2" fill="#27272a" stroke="#059669" stroke-width="1.5" /> <circle cx="14" cy="14" r="2.5" fill="#22c55e" /> <circle cx="14" cy="22" r="2.5" fill="#eab308" /> <line x1="22" y1="12" x2="32" y2="12" stroke="#a1a1aa" stroke-width="2" /> </svg> } @else if (type === 'spall') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#27272a" /> <polygon points="6,6 38,6 38,38 24,38 18,26 6,24" fill="#71717a" /> <polygon points="18,26 26,24 24,38" fill="#ef4444" fill-opacity="0.4" stroke="#ef4444" stroke-width="1" /> </svg> } @else if (type === 'rebar') { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#27272a" /> <line x1="6" y1="38" x2="38" y2="6" stroke="#b45309" stroke-width="4" stroke-dasharray="2 1" /> <circle cx="22" cy="22" r="6" stroke="#ef4444" stroke-width="1.5" fill="none" /> </svg> } @else { <svg class="h-full w-full" viewBox="0 0 44 44" fill="none"> <rect width="44" height="44" fill="#18181b" /> <path d="M 12,38 L 18,26 L 24,20 L 32,6" stroke="#ef4444" stroke-width="2" fill="none" /> <rect x="14" y="14" width="16" height="12" rx="1" fill="#ffffff" fill-opacity="0.2" stroke="#ffffff" stroke-width="0.75" /> </svg> } </div> </ng-template> `, }) export class UiFieldInspectionManagerComponent { protected readonly AlertCircle = AlertCircle protected readonly AlertTriangle = AlertTriangle protected readonly Building2 = Building2 protected readonly Calendar = Calendar protected readonly Camera = Camera protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly ClipboardCheck = ClipboardCheck protected readonly Clock = Clock protected readonly Copy = Copy protected readonly Download = Download protected readonly Eye = Eye protected readonly MapPin = MapPin protected readonly MoreHorizontal = MoreHorizontal protected readonly Percent = Percent protected readonly Plus = Plus protected readonly Search = Search protected readonly ShieldAlert = ShieldAlert protected readonly ShieldCheck = ShieldCheck protected readonly UploadCloud = UploadCloud protected readonly Wrench = Wrench @Input() set initialFilter(v: InspectionFilter | undefined) { this.activeFilter.set(v ?? 'all') } @Input() set initialRecords(v: InspectionRecord[] | undefined) { this.inspectionRecords.set((v ?? defaultRecords).map((r) => ({ ...r }))) } @Input('class') className?: string readonly activeFilter = signal<InspectionFilter>('all') readonly searchQuery = signal('') readonly selectedFacility = signal('all') readonly copiedGpsId = signal<string | null>(null) readonly toastMessage = signal<string | null>(null) readonly previewPhoto = signal<{ photo: PhotoEvidence; record: InspectionRecord } | null>(null) readonly selectedAudit = signal<InspectionRecord | null>(null) readonly isNewAuditOpen = signal(false) readonly inspectionRecords = signal<InspectionRecord[]>(defaultRecords.map((r) => ({ ...r }))) readonly newAuditAsset = signal('AST-HVAC-4091') readonly newAuditFacility = signal('Building A · Rooftop Mechanical Room') readonly newAuditNotes = signal('') readonly newAuditSuccess = signal(false) private timers: ReturnType<typeof setTimeout>[] = [] constructor() { inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t))) } readonly filteredRecords = computed(() => { const filter = this.activeFilter() const facility = this.selectedFacility() const q = this.searchQuery().toLowerCase().trim() return this.inspectionRecords().filter((record) => { if (filter === 'passed' && record.status !== 'passed') return false if (filter === 'critical' && record.status !== 'critical') return false if (filter === 'minor' && record.status !== 'minor') return false if (facility !== 'all' && !record.facility.includes(facility)) return false if (q) { const hay = `${record.assetName} ${record.assetId} ${record.facility} ${record.category}`.toLowerCase() if (!hay.includes(q)) return false } return true }) }) get rootClass(): string { return cn('text-foreground w-full space-y-6', this.className) } filterPillClass(filter: InspectionFilter): string { return cn( 'focus-visible:ring-ring inline-flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none', this.activeFilter() !== filter && 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground', this.activeFilter() === filter && filter === 'all' && 'bg-primary text-primary-foreground font-semibold shadow-xs', this.activeFilter() === filter && filter === 'passed' && 'bg-success font-semibold text-white shadow-xs', this.activeFilter() === filter && filter === 'critical' && 'bg-destructive font-semibold text-white shadow-xs', this.activeFilter() === filter && filter === 'minor' && 'bg-warning font-semibold text-white shadow-xs', ) } scoreClass(status: InspectionStatus): string { return cn( 'font-mono font-semibold', status === 'passed' && 'text-success', status === 'critical' && 'text-destructive', status === 'minor' && 'text-warning', ) } scoreBarClass(status: InspectionStatus): string { return cn( 'h-full rounded-full transition-[width] duration-300', status === 'passed' && 'bg-success', status === 'critical' && 'bg-destructive', status === 'minor' && 'bg-warning', ) } statusBadgeClass(status: InspectionStatus): string { return cn( 'text-xs font-semibold capitalize', status === 'passed' && 'bg-success hover:bg-success/90 text-white', status === 'minor' && 'bg-warning hover:bg-warning/90 text-white', status === 'critical' && 'bg-destructive hover:bg-destructive/90 text-white', ) } signoffClass(status: InspectionStatus): string { return cn( 'inline-flex items-center gap-1 font-mono text-xs font-medium', status === 'critical' ? 'text-destructive' : 'text-success', ) } setSelectedFacility(value: string): void { this.selectedFacility.set(value) } copyGps(gps: string, recordId: string): void { try { void navigator.clipboard?.writeText(gps) } catch { // Clipboard unavailable (jsdom) — toast still confirms the action. } this.copiedGpsId.set(recordId) this.showToast(`GPS Coordinates ${gps} copied to clipboard`) const timer = setTimeout(() => { if (this.copiedGpsId() === recordId) this.copiedGpsId.set(null) }, 2200) this.timers.push(timer) } showToast(msg: string): void { this.toastMessage.set(msg) const timer = setTimeout(() => { if (this.toastMessage() === msg) this.toastMessage.set(null) }, 3000) this.timers.push(timer) } handleOpenPhoto(photo: PhotoEvidence, record: InspectionRecord): void { this.previewPhoto.set({ photo, record }) } handleViewReport(record: InspectionRecord): void { this.selectedAudit.set(record) } handleDownloadPdf(record: InspectionRecord): void { this.showToast(`Downloading certified PDF audit report for ${record.assetId}...`) } handleCreateWorkOrder(record: InspectionRecord): void { this.showToast(`Work Order generated for ${record.assetName} (Assigned to Facilities Ops)`) } handleStartNewInspection(): void { this.isNewAuditOpen.set(true) } onPreviewOpenChange(open: boolean): void { if (!open) this.previewPhoto.set(null) } onAuditOpenChange(open: boolean): void { if (!open) this.selectedAudit.set(null) } submitNewAudit(): void { this.newAuditSuccess.set(true) this.showToast('New audit inspection successfully submitted and recorded!') const timer = setTimeout(() => { this.isNewAuditOpen.set(false) this.newAuditSuccess.set(false) this.newAuditNotes.set('') }, 1400) this.timers.push(timer) } }src/app/components/blocks/field-inspection-manager/index.ts0.2 kB
export { UiFieldInspectionManagerComponent, type InspectionStatus, type InspectionFilter, type PhotoEvidence, type ChecklistItem, type InspectionRecord, } from './field-inspection-manager.component'
Raw manifest:https://uipkge.dev/r/angular/field-inspection-manager.json