- 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
357 lines
19 KiB
HTML
357 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Denya OneCare</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
denya: {
|
|
50: '#eff6ff',
|
|
100: '#dbeafe',
|
|
200: '#bfdbfe',
|
|
300: '#93c5fd',
|
|
400: '#60a5fa',
|
|
500: '#3b82f6',
|
|
600: '#2563eb',
|
|
700: '#1d4ed8',
|
|
800: '#1e40af',
|
|
900: '#1e3a8a',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
[x-cloak] { display: none !important; }
|
|
.status-new { @apply bg-gray-100 text-gray-800; }
|
|
.status-logged { @apply bg-blue-100 text-blue-800; }
|
|
.status-triage { @apply bg-yellow-100 text-yellow-800; }
|
|
.status-assigned { @apply bg-indigo-100 text-indigo-800; }
|
|
.status-accepted { @apply bg-purple-100 text-purple-800; }
|
|
.status-travelling { @apply bg-cyan-100 text-cyan-800; }
|
|
.status-onsite { @apply bg-teal-100 text-teal-800; }
|
|
.status-inprogress { @apply bg-sky-100 text-sky-800; }
|
|
.status-waitingparts { @apply bg-orange-100 text-orange-800; }
|
|
.status-escalated { @apply bg-red-100 text-red-800; }
|
|
.status-completed { @apply bg-green-100 text-green-800; }
|
|
.status-on-field-verification { @apply bg-emerald-100 text-emerald-800; }
|
|
.status-wahab-review { @apply bg-violet-100 text-violet-800; }
|
|
.status-closed { @apply bg-gray-200 text-gray-600; }
|
|
.status-reopened { @apply bg-pink-100 text-pink-800; }
|
|
.priority-urgent { @apply bg-red-100 text-red-800 border-red-300; }
|
|
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
|
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
|
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()">
|
|
<!-- Nav Bar -->
|
|
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex items-center justify-between h-16">
|
|
<!-- Left side -->
|
|
<div class="flex items-center space-x-4">
|
|
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg">
|
|
<svg class="w-8 h-8" 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>
|
|
<span>Denya OneCare</span>
|
|
</a>
|
|
<!-- CS Links -->
|
|
<template x-if="isCS">
|
|
<div class="hidden md:flex space-x-1 ml-6">
|
|
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
|
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
|
</div>
|
|
</template>
|
|
<!-- FM Links -->
|
|
<template x-if="isFM">
|
|
<div class="hidden md:flex space-x-1 ml-6">
|
|
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
|
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
|
</div>
|
|
</template>
|
|
<!-- CEO/Director Links -->
|
|
<template x-if="isExecutive">
|
|
<div class="hidden md:flex space-x-1 ml-6">
|
|
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Right side -->
|
|
<div class="flex items-center space-x-4">
|
|
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
|
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Mobile Nav (CS) -->
|
|
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
|
<template x-if="isCS || isFM">
|
|
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
|
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a>
|
|
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a>
|
|
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a>
|
|
</div>
|
|
</template>
|
|
<template x-if="isExecutive">
|
|
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
|
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a>
|
|
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
|
|
<!-- Loading Overlay -->
|
|
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak>
|
|
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl">
|
|
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Notifications -->
|
|
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
|
<template x-for="toast in toasts" :key="toast.id">
|
|
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
|
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}"
|
|
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
|
<span x-text="toast.message"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<script>
|
|
function app() {
|
|
return {
|
|
// Auth state
|
|
user: JSON.parse(localStorage.getItem('user') || '{}'),
|
|
token: localStorage.getItem('access_token') || null,
|
|
isLoggedIn: !!localStorage.getItem('access_token'),
|
|
currentPath: window.location.pathname,
|
|
|
|
// UI state
|
|
loading: false,
|
|
loadingMessage: '',
|
|
toasts: [],
|
|
|
|
// Role helpers
|
|
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
|
|
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
|
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
|
|
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
|
|
|
init() {
|
|
// Redirect to login if not logged in (skip for login page)
|
|
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
// Verify token on load
|
|
if (this.isLoggedIn) {
|
|
this.fetchMe();
|
|
}
|
|
},
|
|
|
|
async fetchMe() {
|
|
try {
|
|
const res = await fetch('/api/auth/me', {
|
|
headers: this.authHeaders()
|
|
});
|
|
if (res.ok) {
|
|
const userData = await res.json();
|
|
this.user = userData;
|
|
localStorage.setItem('user', JSON.stringify(userData));
|
|
} else if (res.status === 401) {
|
|
this.logout();
|
|
}
|
|
} catch (e) {
|
|
console.error('Auth check failed', e);
|
|
}
|
|
},
|
|
|
|
authHeaders() {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
return headers;
|
|
},
|
|
|
|
async api(method, url, body = null) {
|
|
this.loading = true;
|
|
try {
|
|
const opts = {
|
|
method,
|
|
headers: this.authHeaders()
|
|
};
|
|
if (body && !(body instanceof FormData)) {
|
|
opts.body = JSON.stringify(body);
|
|
} else if (body instanceof FormData) {
|
|
opts.body = body;
|
|
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
|
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
|
}
|
|
const res = await fetch(url, opts);
|
|
if (res.status === 401 && this.currentPath !== '/login') {
|
|
this.logout();
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
return await res.json();
|
|
} catch (e) {
|
|
this.showToast(e.message, 'error');
|
|
throw e;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
apiGet(url) { return this.api('GET', url); },
|
|
apiPost(url, body) { return this.api('POST', url, body); },
|
|
apiPatch(url, body) { return this.api('PATCH', url, body); },
|
|
apiDelete(url) { return this.api('DELETE', url); },
|
|
|
|
async login(email, password) {
|
|
try {
|
|
this.loading = true;
|
|
this.loadingMessage = 'Signing in...';
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
|
throw new Error(err.detail);
|
|
}
|
|
const data = await res.json();
|
|
localStorage.setItem('access_token', data.access_token);
|
|
localStorage.setItem('refresh_token', data.refresh_token);
|
|
this.token = data.access_token;
|
|
|
|
// Fetch user info
|
|
const me = await this.apiGet('/api/auth/me');
|
|
this.user = me;
|
|
localStorage.setItem('user', JSON.stringify(me));
|
|
this.isLoggedIn = true;
|
|
|
|
// Role-based redirect
|
|
const role = me.role;
|
|
if (['CS Rep', 'CS Manager'].includes(role)) {
|
|
window.location.href = '/dashboard/cs';
|
|
} else if (['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(role)) {
|
|
window.location.href = '/dashboard/fm';
|
|
} else if (['CEO', 'Director'].includes(role)) {
|
|
window.location.href = '/dashboard/ceo';
|
|
} else {
|
|
window.location.href = '/tickets';
|
|
}
|
|
} catch (e) {
|
|
this.showToast(e.message, 'error');
|
|
throw e;
|
|
} finally {
|
|
this.loading = false;
|
|
this.loadingMessage = '';
|
|
}
|
|
},
|
|
|
|
logout() {
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
localStorage.removeItem('user');
|
|
this.user = {};
|
|
this.token = null;
|
|
this.isLoggedIn = false;
|
|
window.location.href = '/login';
|
|
},
|
|
|
|
showToast(message, type = 'info', duration = 4000) {
|
|
const id = Date.now() + Math.random();
|
|
this.toasts.push({ id, message, type, duration });
|
|
},
|
|
|
|
statusClass(status) {
|
|
if (!status) return 'bg-gray-100 text-gray-800';
|
|
const map = {
|
|
'new': 'status-new',
|
|
'logged': 'status-logged',
|
|
'triage': 'status-triage',
|
|
'assigned': 'status-assigned',
|
|
'accepted': 'status-accepted',
|
|
'travelling': 'status-travelling',
|
|
'on site': 'status-onsite',
|
|
'in progress': 'status-inprogress',
|
|
'waiting parts': 'status-waitingparts',
|
|
'escalated': 'status-escalated',
|
|
'completed': 'status-completed',
|
|
'on-field verification': 'status-on-field-verification',
|
|
'wahab review': 'status-wahab-review',
|
|
'closed': 'status-closed',
|
|
'reopened': 'status-reopened'
|
|
};
|
|
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
|
},
|
|
|
|
priorityBadge(priority) {
|
|
const labels = { urgent: '🔴 Urgent', high: '🟠 High', medium: '🟡 Medium', low: '🟢 Low' };
|
|
return labels[priority] || priority;
|
|
},
|
|
|
|
priorityClass(priority) {
|
|
const map = { urgent: 'priority-urgent', high: 'priority-high', medium: 'priority-medium', low: 'priority-low' };
|
|
return map[priority] || 'bg-gray-100 text-gray-800';
|
|
},
|
|
|
|
formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
},
|
|
|
|
formatDateShort(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
},
|
|
|
|
timeSince(dateStr) {
|
|
if (!dateStr) return '';
|
|
const now = new Date();
|
|
const d = new Date(dateStr);
|
|
const diffMs = now - d;
|
|
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
if (diffHrs < 1) return '< 1h';
|
|
if (diffHrs < 24) return `${diffHrs}h`;
|
|
return `${diffDays}d`;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|