Files
denya-onecare/app/templates/dashboard/fm.html
T
root 9aa7971a28 feat: Sprint 3 frontend dashboards with Alpine.js + Jinja2
- Login page with JWT auth and role-based redirect
- CS Dashboard: KPI cards, priority breakdown, recent tickets
- FM Dashboard: tech workload, aging analysis, emergency alerts
- CEO Dashboard: executive KPIs, charts, risk indicators
- All Issues: filterable/sortable ticket table with pagination
- Create Issue: form with category tree, property/unit selector, photo uploads
- Issue Detail: full timeline, photo gallery, SLA status, action modals
- Role-based nav bar: CS/FM/Executive links adapt to user role
- Base template: shared Alpine.js app state, toast notifications, loading overlay
2026-07-23 19:01:26 +00:00

249 lines
14 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div x-data="fmDashboard()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Facilities Management Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
</div>
<!-- Emergency Alert -->
<div x-show="emergencyCount > 0" class="mb-6 p-4 bg-red-50 border-2 border-red-300 rounded-xl flex items-center space-x-3">
<svg class="w-8 h-8 text-red-600 flex-shrink-0 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<div>
<p class="font-bold text-red-800"><span x-text="emergencyCount"></span> Emergency <span x-text="emergencyCount === 1 ? 'Job' : 'Jobs'"></span> Require Immediate Attention!</p>
<p class="text-sm text-red-600">View in the table below</p>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Active Jobs</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.activeJobs || 0"></p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Due Today</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.dueToday || 0"></p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Waiting Parts</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.waitingParts || 0"></p>
</div>
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Jobs East / West</p>
<p class="text-3xl font-bold text-gray-900 mt-1"><span x-text="kpi.eastJobs || 0"></span> / <span x-text="kpi.westJobs || 0"></span></p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Technician Workload + Aging Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Tech Workload -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Technician Workload</h3>
<div class="space-y-3">
<template x-for="tech in techWorkload" :key="tech.id">
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="tech.name.charAt(0)"></div>
<div>
<p class="text-sm font-medium text-gray-700" x-text="tech.name"></p>
<p class="text-xs text-gray-400" x-text="tech.specialty || 'Technician'"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-lg font-bold" :class="tech.activeJobs > 3 ? 'text-red-600' : tech.activeJobs > 1 ? 'text-yellow-600' : 'text-green-600'" x-text="tech.activeJobs"></span>
<span class="text-xs text-gray-400">active</span>
</div>
</div>
</template>
<div x-show="!techWorkload.length" class="text-gray-400 text-sm py-6 text-center">No active assignments</div>
</div>
</div>
<!-- Aging Analysis -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Aging Analysis</h3>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<span class="text-sm font-medium text-green-700">&lt; 24h</span>
<span class="text-2xl font-bold text-green-700" x-text="aging.under24h || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
<span class="text-sm font-medium text-yellow-700">1-2 days</span>
<span class="text-2xl font-bold text-yellow-700" x-text="aging.oneToTwoDays || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<span class="text-sm font-medium text-orange-700">3-5 days</span>
<span class="text-2xl font-bold text-orange-700" x-text="aging.threeToFiveDays || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<span class="text-sm font-medium text-red-700">Overdue &gt; 5d</span>
<span class="text-2xl font-bold text-red-700" x-text="aging.overFiveDays || 0"></span>
</div>
</div>
</div>
</div>
<!-- Active Tickets Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Active Tickets</h3>
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left">Ticket</th>
<th class="px-5 py-3 text-left">Status</th>
<th class="px-5 py-3 text-left">Priority</th>
<th class="px-5 py-3 text-left">Technician</th>
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Age</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in activeTickets" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50' : ''" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
</tr>
</template>
<tr x-show="!activeTickets.length">
<td colspan="6" class="px-5 py-12 text-center text-gray-400">No active tickets</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
function fmDashboard() {
return {
kpi: {},
techWorkload: [],
aging: {},
activeTickets: [],
emergencyCount: 0,
async init() {
await this.loadData();
},
async loadData() {
try {
const data = await app().apiGet('/api/tickets?page_size=200');
if (!data?.items) return;
const all = data.items;
// Active: not closed/completed
const active = all.filter(t => !['Closed', 'Completed'].includes(t.status));
this.activeTickets = active;
// KPIs
this.kpi.activeJobs = active.length;
this.kpi.waitingParts = active.filter(t => t.status === 'Waiting Parts').length;
// Due today
const today = new Date();
today.setHours(23, 59, 59, 0);
const dueToday = active.filter(t => {
if (!t.sla_deadline) return false;
const deadline = new Date(t.sla_deadline);
return deadline <= today;
});
this.kpi.dueToday = dueToday.length;
// Emergency
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
// East vs West — we need full tickets with unit info
// For now, estimate from overall data or show placeholder
// We'll load again with property filter or just use total
try {
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
const eastTotal = east?.total || 0;
const westTotal = west?.total || 0;
this.kpi.eastJobs = eastTotal;
this.kpi.westJobs = westTotal;
} catch (e) { console.error('Property stats error', e); }
// Tech workload (simulated from assigned_to counts)
const techMap = {};
active.forEach(t => {
if (t.assigned_to) {
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
techMap[t.assigned_to].activeJobs++;
}
});
this.techWorkload = Object.values(techMap).sort((a, b) => b.activeJobs - a.activeJobs);
// Aging
const now = new Date();
this.aging = {
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
oneToTwoDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 1 && diff < 2;
}).length,
threeToFiveDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 2 && diff < 5;
}).length,
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
};
} catch (e) { console.error('FM data load error', e); }
}
}
}
</script>
{% endblock %}