Maintenance Request Ticket
blockreal-estateResident repair and facility work order submission portal with urgency priority and photo dropzone.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/maintenance-request-ticket.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/maintenance-request-ticket.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/maintenance-request-ticket.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/maintenance-request-ticket.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/maintenance-request-ticketInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
initialCategory | string | 'appliances' | optional |
initialUrgency | 'routine''standard''emergency' | 'standard' | optional |
initialLocation | string | 'kitchen' | optional |
initialDescription | string | 'Kitchen sink garbage disposal is jammed and leaking slig… | optional |
initialPermission | 'granted''call_first' | 'granted' | optional |
initialFiles | AttachedFile[] | () => [ { id: 'f-1', name: 'kitchen-sink-leak-pipe.jpg', … | optional |
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
}npm dependencies
Files installed (1)
app/components/blocks/MaintenanceRequestTicket.vue37.7 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, ref } from 'vue' import { AlertCircle, Bug, Building2, Check, CheckCircle2, ChevronDown, Clock, Droplets, FileImage, FileVideo, Flame, KeyRound, PhoneCall, Plus, RefreshCw, ShieldAlert, ShieldCheck, Trash2, UploadCloud, UserCheck, Wrench, Zap, } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' export interface AttachedFile { id: string name: string size: string type: 'image' | 'video' uploadedAt: string } export interface MaintenanceRequestTicketProps { class?: HTMLAttributes['class'] initialCategory?: string initialUrgency?: 'routine' | 'standard' | 'emergency' initialLocation?: string initialDescription?: string initialPermission?: 'granted' | 'call_first' initialFiles?: AttachedFile[] } const props = withDefaults(defineProps<MaintenanceRequestTicketProps>(), { initialCategory: 'appliances', initialUrgency: 'standard', initialLocation: 'kitchen', initialDescription: 'Kitchen sink garbage disposal is jammed and leaking slightly under the cabinet when water runs. Motor makes a low humming sound.', initialPermission: 'granted', initialFiles: () => [ { 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', }, ], }) // Form state const category = ref<string>(props.initialCategory) const urgency = ref<'routine' | 'standard' | 'emergency'>(props.initialUrgency) const location = ref<string>(props.initialLocation) const description = ref<string>(props.initialDescription) const permission = ref<'granted' | 'call_first'>(props.initialPermission) const attachedFiles = ref<AttachedFile[]>([...props.initialFiles]) const isDragging = ref(false) const showOpenRequests = ref(false) const isSubmitting = ref(false) const isSubmitted = ref(false) const draftSaved = ref(false) const generatedTicketId = ref('MNT-9042') // Category options const categories = [ { 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', }, ] // Urgency options const urgencyOptions = [ { value: 'routine', label: 'Low / Routine', badge: '3–5 Business Days', badgeVariant: 'outline' as const, description: 'Non-urgent preventative maintenance or routine hardware adjustments.', }, { value: 'standard', label: 'Medium / Standard 48h', badge: '24–48h SLA', badgeVariant: 'secondary' as const, description: 'Standard repair with moderate impact on daily convenience or appliance usage.', }, { value: 'emergency', label: 'High / Emergency 2h SLA', badge: 'Emergency < 2h', badgeVariant: 'destructive' as const, description: 'Immediate hazard to property or habitability. Triggers 24/7 on-call dispatch.', }, ] // Location options const locationOptions = [ { 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' }, ] // Sample quick description chips const quickSnippets = [ 'Motor humming but not spinning', 'Water leaking under sink', 'Reset button tripped', 'Slow draining', ] const maxChars = 500 const descriptionLength = computed(() => description.value.length) function selectCategory(id: string) { category.value = id } function appendSnippet(snippet: string) { if (!description.value) { description.value = snippet } else if (!description.value.includes(snippet)) { description.value = `${description.value.trim()} ${snippet}.` } } function removeFile(id: string) { attachedFiles.value = attachedFiles.value.filter((f) => f.id !== id) } function handleDrop(e: DragEvent) { e.preventDefault() isDragging.value = false if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { for (let i = 0; i < e.dataTransfer.files.length; i++) { const file = e.dataTransfer.files[i] attachedFiles.value.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', }) } } } function simulateAddFile() { const sampleNames = ['cabinet-water-mark.jpg', 'under-sink-plumbing.jpg', 'noise-recording.mp4'] const randomName = sampleNames[attachedFiles.value.length % sampleNames.length] attachedFiles.value.push({ id: `f-${Date.now()}`, name: randomName, size: '3.1 MB', type: randomName.endsWith('.mp4') ? 'video' : 'image', uploadedAt: 'Just now', }) } function handleSaveDraft() { draftSaved.value = true setTimeout(() => { draftSaved.value = false }, 3000) } function handleSubmit() { isSubmitting.value = true setTimeout(() => { isSubmitting.value = false isSubmitted.value = true generatedTicketId.value = `MNT-${Math.floor(1000 + Math.random() * 9000)}` }, 700) } function handleReset() { isSubmitted.value = false category.value = 'appliances' urgency.value = 'standard' location.value = 'kitchen' description.value = 'Kitchen sink garbage disposal is jammed and leaking slightly under the cabinet when water runs. Motor makes a low humming sound.' permission.value = 'granted' attachedFiles.value = [ { 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', }, ] } </script> <template> <div data-slot="maintenance-request-ticket" :class="cn('w-full space-y-6', props.class)"> <!-- Header Banner --> <Card class="border-border shadow-xs"> <CardContent 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> <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 </Badge> </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 variant="outline" size="sm" class="gap-1.5 shadow-xs" @click="showOpenRequests = !showOpenRequests"> <span>{{ showOpenRequests ? 'Hide Open Requests' : 'View Open Requests' }}</span> <ChevronDown :class="cn('size-3.5 transition-transform duration-200', showOpenRequests && 'rotate-180')" /> </Button> </div> </div> <!-- Collapsible Active Work Orders Panel --> <div v-if="showOpenRequests" 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"> <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> <Badge variant="secondary" class="text-xs">Technician Assigned</Badge> </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 > <Badge variant="outline" class="text-xs">Living Room</Badge> </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"> <Badge variant="info" class="text-xs">In Progress</Badge> </div> </div> </div> </CardContent> </Card> <!-- 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"> <!-- Submission Success State --> <Card v-if="isSubmitted" class="border-success/30 bg-success/5 dark:bg-success/10 shadow-xs"> <CardContent 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"> <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 class="gap-1.5 shadow-xs" @click="handleReset"> <RefreshCw class="size-4" /> <span>Submit Another Request</span> </Button> </div> </CardContent> </Card> <!-- Work Order Submission Form --> <Card v-else class="border-border shadow-xs"> <CardHeader class="pb-4"> <CardTitle class="text-lg">Work Order Details</CardTitle> <CardDescription> Complete the fields below to schedule a maintenance technician visit. </CardDescription> </CardHeader> <CardContent 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"> <button v-for="cat in categories" :key="cat.id" type="button" :aria-pressed="category === cat.id" :class=" 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', category === cat.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', ) " @click="selectCategory(cat.id)" > <div class="flex w-full items-center justify-between"> <div :class="cn('flex size-8 items-center justify-center rounded-md', cat.badgeColor)"> <component :is="cat.icon" class="size-4.5" /> </div> <div :class=" cn( 'flex size-4 items-center justify-center rounded-full border transition-colors', category === cat.id ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/30 opacity-0 group-hover:opacity-60', ) " > <Check v-if="category === cat.id" 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> <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 --> <div v-if="urgency === 'emergency'" class="border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-3 rounded-lg border p-3.5 text-xs" > <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> <RadioGroup v-model="urgency" class="grid gap-3 sm:grid-cols-3"> <div v-for="opt in urgencyOptions" :key="opt.value" :class=" cn( 'border-border relative flex cursor-pointer flex-col justify-between gap-3 rounded-lg border p-3.5 transition-colors', urgency === opt.value ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1' : 'bg-card hover:bg-muted/40 hover:border-muted-foreground/30', ) " @click="urgency = opt.value as any" > <div class="flex items-start justify-between gap-2"> <div class="flex items-center gap-2"> <RadioGroupItem :id="`urgency-${opt.value}`" :value="opt.value" /> <label :for="`urgency-${opt.value}`" class="text-foreground cursor-pointer text-xs font-semibold select-none" > {{ opt.label }} </label> </div> </div> <div> <Badge :variant="opt.badgeVariant" class="mb-1 text-xs"> {{ opt.badge }} </Badge> <p class="text-muted-foreground text-xs leading-relaxed"> {{ opt.description }} </p> </div> </div> </RadioGroup> </div> <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> <Select v-model="location"> <SelectTrigger class="w-full"> <SelectValue placeholder="Select location in unit..." /> </SelectTrigger> <SelectContent> <SelectGroup> <SelectLabel>Unit 4B Interior Areas</SelectLabel> <SelectItem v-for="loc in locationOptions" :key="loc.value" :value="loc.value"> {{ loc.label }} </SelectItem> </SelectGroup> </SelectContent> </Select> </div> <Separator /> <!-- 4. Issue Description --> <div class="space-y-3"> <div class="flex items-center justify-between"> <label for="mnt-description" class="text-foreground text-sm font-semibold"> 4. Issue Description <span class="text-destructive">*</span> </label> <span class="text-xs" :class="descriptionLength > maxChars ? 'text-destructive font-medium' : 'text-muted-foreground'" > {{ descriptionLength }} / {{ maxChars }} </span> </div> <Textarea id="mnt-description" v-model="description" :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"> <button v-for="chip in quickSnippets" :key="chip" 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)" > <Plus class="size-3" /> <span>{{ chip }}</span> </button> </div> </div> </div> <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> <RadioGroup v-model="permission" class="grid gap-3 sm:grid-cols-2"> <div :class=" cn( 'border-border relative flex cursor-pointer flex-col justify-between gap-2.5 rounded-lg border p-4 transition-colors', permission === 'granted' ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1' : 'bg-card hover:bg-muted/40 hover:border-muted-foreground/30', ) " @click="permission = 'granted'" > <div class="flex items-start gap-2.5"> <RadioGroupItem id="perm-granted" value="granted" class="mt-0.5" /> <div class="space-y-1"> <label for="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"> <UserCheck class="size-3.5" /> <span>Faster dispatch window</span> </div> </div> <div :class=" cn( 'border-border relative flex cursor-pointer flex-col justify-between gap-2.5 rounded-lg border p-4 transition-colors', permission === 'call_first' ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1' : 'bg-card hover:bg-muted/40 hover:border-muted-foreground/30', ) " @click="permission = 'call_first'" > <div class="flex items-start gap-2.5"> <RadioGroupItem id="perm-call" value="call_first" class="mt-0.5" /> <div class="space-y-1"> <label for="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"> <Clock class="size-3.5" /> <span>Requires resident appointment</span> </div> </div> </RadioGroup> </div> <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=" cn( 'cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors', isDragging ? 'border-primary bg-primary/10' : 'border-border bg-muted/20 hover:border-muted-foreground/40 hover:bg-muted/40', ) " @dragover.prevent="isDragging = true" @dragleave.prevent="isDragging = false" @drop="handleDrop" @click="simulateAddFile" > <div class="bg-muted text-muted-foreground mx-auto flex size-10 items-center justify-center rounded-full" > <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 --> <div v-if="attachedFiles.length > 0" class="space-y-2 pt-1"> <div v-for="f in attachedFiles" :key="f.id" 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"> <FileVideo v-if="f.type === 'video'" class="size-4" /> <FileImage v-else 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"> <Badge variant="outline" class="border-success/30 text-success text-xs"> Attached </Badge> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-destructive size-7" @click="removeFile(f.id)" > <Trash2 class="size-3.5" /> <span class="sr-only">Remove file</span> </Button> </div> </div> </div> </div> </CardContent> <CardFooter 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 variant="outline" size="sm" class="shadow-xs" :disabled="isSubmitting" @click="handleSaveDraft"> <span>{{ draftSaved ? 'Draft Saved!' : 'Save as Draft' }}</span> </Button> <span v-if="draftSaved" class="text-success flex items-center gap-1 text-xs"> <Check class="size-3" /> Saved locally </span> </div> <Button size="default" class="w-full gap-2 shadow-xs sm:w-auto" :disabled="isSubmitting || !description.trim()" @click="handleSubmit" > <Wrench v-if="!isSubmitting" class="size-4" /> <RefreshCw v-else class="size-4 animate-spin" /> <span>{{ isSubmitting ? 'Dispatching Work Order...' : 'Submit Maintenance Request' }}</span> </Button> </CardFooter> </Card> </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 --> <Card class="border-destructive/40 bg-destructive/5 dark:bg-destructive/10 shadow-xs"> <CardHeader class="pb-3"> <div class="text-destructive flex items-center gap-2"> <ShieldAlert class="size-5 shrink-0" /> <CardTitle class="text-destructive text-base">24/7 Emergency Hotline</CardTitle> </div> <CardDescription class="text-foreground/80 text-xs"> For urgent situations threatening life safety, gas leaks, or active flooding. </CardDescription> </CardHeader> <CardContent 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> <Button as="a" href="tel:4155550192" variant="destructive" class="w-full gap-2 shadow-xs"> <PhoneCall class="size-4" /> <span>Call Emergency Dispatch</span> </Button> </CardContent> </Card> <!-- Service Level Agreement (SLA) Card --> <Card class="border-border shadow-xs"> <CardHeader class="pb-3"> <div class="flex items-center gap-2"> <Clock class="text-primary size-4.5" /> <CardTitle class="text-base">Service Level Agreement (SLA)</CardTitle> </div> <CardDescription class="text-xs"> Response benchmarks for Pacific Heights property maintenance. </CardDescription> </CardHeader> <CardContent 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> <Badge variant="destructive" class="text-xs">< 2 Hours</Badge> </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> <Badge variant="secondary" class="text-xs">24–48 Hours</Badge> </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> <Badge variant="outline" class="text-xs">3–5 Days</Badge> </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"> <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> </CardContent> </Card> <!-- Building Operations Info Card --> <Card class="border-border shadow-xs"> <CardHeader class="pb-3"> <div class="flex items-center gap-2"> <Building2 class="text-muted-foreground size-4.5" /> <CardTitle class="text-base">Building Operations</CardTitle> </div> </CardHeader> <CardContent 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> </CardContent> </Card> </div> </div> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/maintenance-request-ticket.json