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
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="ceoDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
|
||||
</div>
|
||||
|
||||
<!-- Executive KPI Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Critical (Urgent)</p>
|
||||
<p class="text-3xl font-bold text-red-600 mt-1" x-text="kpi.criticalCount || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">SLA Compliance</p>
|
||||
<p class="text-3xl font-bold mt-1" :class="kpi.slaCompliance >= 90 ? 'text-green-600' : kpi.slaCompliance >= 70 ? 'text-yellow-600' : 'text-red-600'" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResolutionTime || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Response</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Reopened This Week</p>
|
||||
<p class="text-2xl font-bold text-orange-600 mt-1" x-text="kpi.reopenedThisWeek || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Total Tickets</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.totalTickets || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
|
||||
<p class="text-2xl font-bold mt-1" :class="kpi.completionRate >= 70 ? 'text-green-600' : 'text-yellow-600'" x-text="(kpi.completionRate || 0) + '%'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Complaints by Property (Simple Bar) -->
|
||||
<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">Complaints by Property</h3>
|
||||
<div class="flex items-end space-x-6 h-40 px-2">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.east || 0"></span>
|
||||
<div class="w-full bg-denya-200 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.east || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">East</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.west || 0"></span>
|
||||
<div class="w-full bg-denya-400 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.west || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">West</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complaints by Category -->
|
||||
<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">Complaints by Category</h3>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<template x-for="item in charts.byCategory" :key="item.name">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-600 w-20 truncate" x-text="item.name"></span>
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all" :style="'width: ' + (item.count / Math.max(charts.byCategoryMax, 1) * 100) + '%'" :class="item.color || 'bg-denya-500'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-600 w-8 text-right" x-text="item.count"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!charts.byCategory?.length" class="text-gray-400 text-sm py-6 text-center">No data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Priority Distribution -->
|
||||
<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">Tickets by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-red-600">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-orange-600">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-yellow-600">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-green-600">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Trends (Simple) -->
|
||||
<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">Monthly Trends</h3>
|
||||
<div class="flex items-end space-x-1 h-40 overflow-x-auto pb-2">
|
||||
<template x-for="(item, idx) in charts.monthlyTrend" :key="idx">
|
||||
<div class="flex-1 flex flex-col items-center min-w-[30px]">
|
||||
<span class="text-xs font-medium text-gray-600 mb-1" x-text="item.count"></span>
|
||||
<div class="w-full bg-denya-300 rounded-t transition-all" :style="'height: ' + Math.max(item.count / Math.max(charts.monthlyMax || 1, 1) * 100, 4) + 'px'"></div>
|
||||
<span class="text-xs text-gray-400 mt-1" x-text="item.month"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!charts.monthlyTrend?.length" class="text-gray-400 text-sm py-6 text-center w-full">No data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Indicators -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Risk Indicators</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="p-3 rounded-lg" :class="indicators.openEmergencies > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.openEmergencies > 0 ? 'bg-red-500 animate-pulse' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Open Emergencies</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.openEmergencies || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-50 border border-orange-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Reopened This Week</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.reopenedThisWeek || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.overdueJobs > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.overdueJobs > 0 ? 'bg-red-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Overdue (Past SLA)</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.overdueJobs || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.pendingVerification > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.pendingVerification > 0 ? 'bg-yellow-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Pending Verification</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.pendingVerification || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ceoDashboard() {
|
||||
return {
|
||||
kpi: {},
|
||||
charts: { byProperty: {}, byCategory: [], byPriority: {}, monthlyTrend: [], byCategoryMax: 1, monthlyMax: 1 },
|
||||
indicators: {},
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const allData = await app().apiGet('/api/tickets?page_size=500');
|
||||
if (!allData?.items) return;
|
||||
const all = allData.items;
|
||||
const total = allData.total || all.length;
|
||||
|
||||
// Basic KPIs
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status));
|
||||
const urgent = all.filter(t => t.priority === 'urgent');
|
||||
const withSLA = all.filter(t => t.sla_deadline);
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
// Monthly restored
|
||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const reopenedThisWeek = all.filter(t => t.status === 'Reopened' && new Date(t.updated_at) >= oneWeekAgo).length;
|
||||
|
||||
this.kpi = {
|
||||
openTickets: open.length,
|
||||
criticalCount: urgent.length,
|
||||
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100,
|
||||
avgResolutionTime: this.calcAvgResolution(all),
|
||||
avgResponseTime: this.calcAvgResponse(all),
|
||||
reopenedThisWeek,
|
||||
totalTickets: total,
|
||||
completionRate: total ? Math.round((closed.length / total) * 100) : 0,
|
||||
};
|
||||
|
||||
// Chart data
|
||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||
open.forEach(t => { if (t.priority) byPriority[t.priority]++; });
|
||||
this.charts.byPriority = byPriority;
|
||||
|
||||
// Monthly trends (last 6 months)
|
||||
const months = {};
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
all.forEach(t => {
|
||||
const d = new Date(t.created_at);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
months[key] = (months[key] || 0) + 1;
|
||||
});
|
||||
const sortedMonths = Object.keys(months).sort().slice(-6);
|
||||
this.charts.monthlyTrend = sortedMonths.map(k => {
|
||||
const [y, m] = k.split('-').map(Number);
|
||||
return { month: monthNames[m], count: months[k] };
|
||||
});
|
||||
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
||||
|
||||
// By property
|
||||
const propData = { east: 0, west: 0 };
|
||||
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
|
||||
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');
|
||||
propData.east = east?.total || 0;
|
||||
propData.west = west?.total || 0;
|
||||
} catch (e) { /* api may not support property filter directly */ }
|
||||
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||
|
||||
// By category — use categories endpoint
|
||||
try {
|
||||
const cats = await app().apiGet('/api/tickets/categories/flat');
|
||||
const catCounts = {};
|
||||
all.forEach(t => {
|
||||
if (t.category_id) {
|
||||
const cat = cats?.find(c => c.id === t.category_id);
|
||||
const name = cat ? cat.name : `Cat #${t.category_id}`;
|
||||
catCounts[name] = (catCounts[name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const colors = ['bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-purple-500', 'bg-pink-500', 'bg-indigo-500', 'bg-teal-500'];
|
||||
this.charts.byCategory = Object.entries(catCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name, count], i) => ({ name, count, color: colors[i % colors.length] }));
|
||||
this.charts.byCategoryMax = Math.max(...this.charts.byCategory.map(c => c.count), 1);
|
||||
} catch (e) { console.error('Category stats error', e); }
|
||||
|
||||
// Risk indicators
|
||||
this.indicators = {
|
||||
openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length,
|
||||
reopenedThisWeek,
|
||||
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length,
|
||||
pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
|
||||
};
|
||||
|
||||
} catch (e) { console.error('CEO data load error', e); }
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
if (!open.length) return '—';
|
||||
const avgHrs = open.reduce((sum, t) => sum + Math.min((new Date() - new Date(t.created_at)) / (1000 * 60 * 60), 168), 0) / open.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
},
|
||||
|
||||
calcAvgResolution(all) {
|
||||
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
|
||||
if (!closed.length) return '—';
|
||||
const avgHrs = closed.reduce((sum, t) => {
|
||||
const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||
return sum + diff;
|
||||
}, 0) / closed.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="csDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Customer Service Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||
</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">New Today</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.newToday || 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</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">Open Tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 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="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">Avg Response Time</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</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">SLA Compliance</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Breakdown + Escalated -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Open by Priority -->
|
||||
<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">Open by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-orange-600 font-medium">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-yellow-600 font-medium">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-green-600 font-medium">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oldest Unassigned -->
|
||||
<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">Oldest Unassigned</h3>
|
||||
<div x-show="!oldestUnassigned" class="text-gray-400 text-sm py-8 text-center">No unassigned tickets</div>
|
||||
<template x-if="oldestUnassigned">
|
||||
<div>
|
||||
<a :href="'/tickets/' + oldestUnassigned.id" class="block p-3 bg-orange-50 rounded-lg border border-orange-200 hover:bg-orange-100 transition">
|
||||
<div class="font-medium text-sm" x-text="oldestUnassigned.ticket_number"></div>
|
||||
<div class="text-xs text-gray-600 mt-1" x-text="oldestUnassigned.description?.substring(0, 80)"></div>
|
||||
<div class="text-xs text-gray-400 mt-1">Waiting <span x-text="timeSince(oldestUnassigned.created_at)"></span></div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Escalated -->
|
||||
<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">Escalated Tickets</h3>
|
||||
<div class="text-3xl font-bold text-red-600 mb-3" x-text="kpi.escalatedCount || 0"></div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<template x-for="ticket in escalatedTickets" :key="ticket.id">
|
||||
<a :href="'/tickets/' + ticket.id" class="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium" x-text="ticket.ticket_number"></span>
|
||||
<span class="text-xs text-red-600 font-medium" x-text="ticket.priority"></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-0.5" x-text="ticket.description?.substring(0, 60)"></div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!escalatedTickets.length" class="text-gray-400 text-sm py-4 text-center">No escalated tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent 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">Recent 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">Description</th>
|
||||
<th class="px-5 py-3 text-left">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in recentTickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" @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 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!recentTickets.length">
|
||||
<td colspan="5" class="px-5 py-12 text-center text-gray-400">No tickets found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function csDashboard() {
|
||||
return {
|
||||
kpi: { byPriority: {} },
|
||||
recentTickets: [],
|
||||
escalatedTickets: [],
|
||||
oldestUnassigned: null,
|
||||
|
||||
async init() {
|
||||
await this.loadKPIs();
|
||||
await this.loadRecentTickets();
|
||||
await this.loadEscalated();
|
||||
},
|
||||
|
||||
async loadKPIs() {
|
||||
try {
|
||||
const tickets = await app().apiGet('/api/tickets?page_size=200');
|
||||
if (!tickets?.items) return;
|
||||
const all = tickets.items;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// New today
|
||||
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
|
||||
|
||||
// Open tickets (not closed/completed)
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
// By priority
|
||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
|
||||
|
||||
// Oldest unassigned
|
||||
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed'].includes(t.status))
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
this.oldestUnassigned = unassigned[0] || null;
|
||||
|
||||
// SLA compliance (rough: tickets with sla_deadline not breached)
|
||||
const withSLA = all.filter(t => t.sla_deadline);
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
this.kpi = {
|
||||
newToday,
|
||||
openTickets: open.length,
|
||||
byPriority,
|
||||
escalatedCount: all.filter(t => t.status === 'Escalated').length,
|
||||
avgResponseTime: this.calcAvgResponse(all),
|
||||
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100
|
||||
};
|
||||
} catch (e) { console.error('KPI load error', e); }
|
||||
},
|
||||
|
||||
async loadRecentTickets() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?page_size=20');
|
||||
this.recentTickets = data?.items || [];
|
||||
} catch (e) { console.error('Recent tickets load error', e); }
|
||||
},
|
||||
|
||||
async loadEscalated() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?status=Escalated&page_size=10');
|
||||
this.escalatedTickets = data?.items || [];
|
||||
} catch (e) { console.error('Escalated load error', e); }
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
// Rough approximation — in real system this would come from timeline analysis
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
if (!open.length) return '—';
|
||||
const avgHrs = open.reduce((sum, t) => {
|
||||
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||
return sum + Math.min(diff, 168); // cap at 1 week
|
||||
}, 0) / open.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,248 @@
|
||||
{% 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">< 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 > 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 %}
|
||||
Reference in New Issue
Block a user