Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s
Deploy Ladill Queue / deploy (push) Successful in 56s
Phases 1–6: tickets, counters, displays, appointments, workflows, rules, analytics, reports, feedback, admin, device API, and Gitea deploy workflow for queue.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
|
||||
@media (max-width: 1023px) {
|
||||
.mobile-action-bar {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.mobile-action-bar__toolbar {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid rgb(226 232 240);
|
||||
background-color: #fff;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.mobile-action-bar__inset-fill {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
height: env(safe-area-inset-bottom, 0px);
|
||||
min-height: max(env(safe-area-inset-bottom, 0px), 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra white bleed behind the bar for any remaining viewport gap on mobile */
|
||||
.qr-mobile-bottom-bleed {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 48;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
height: calc(4rem + env(safe-area-inset-bottom, 0px) + 24px);
|
||||
min-height: calc(4rem + 24px);
|
||||
}
|
||||
|
||||
html.qr-mobile-page {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
html.qr-mobile-page body {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Payment QR landing: lock scroll and keep the amount sheet flush to the screen bottom */
|
||||
@media (max-width: 767px) {
|
||||
html.mini-payment-page,
|
||||
html.mini-payment-page body {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.mini-payment-page body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.mini-payment-sheet {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mini-payment-bottom-bleed {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 19;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
height: calc(12rem + env(safe-area-inset-bottom, 0px));
|
||||
min-height: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-stats-card {
|
||||
width: calc(66.666667% - 0.5rem);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237));
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-primary-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary-lg {
|
||||
padding: 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.btn-fab {
|
||||
display: inline-flex;
|
||||
height: 2.75rem;
|
||||
width: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-fab:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mobile-stats-card {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ladill app launcher — cap at 5 rows (15 apps), always-visible scroll indicator */
|
||||
.ladill-launcher-apps-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps {
|
||||
max-height: calc(5 * 5.25rem + 4 * 0.25rem);
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(148 163 184) rgb(241 245 249);
|
||||
margin-right: -0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-track {
|
||||
background: rgb(241 245 249);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(148 163 184);
|
||||
border-radius: 9999px;
|
||||
min-height: 2.5rem;
|
||||
border: 2px solid rgb(241 245 249);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.ladill-launcher-scroll-more {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0.125rem;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
-webkit-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255);
|
||||
color: rgb(100 116 139);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
import { registerKioskFlow } from './kiosk-flow';
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
document.addEventListener('alpine:init', () => registerKioskFlow(Alpine));
|
||||
|
||||
// In-app notification bell + dropdown.
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
unreadUrl: config.unreadUrl || '/notifications/unread',
|
||||
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
||||
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
||||
indexUrl: config.indexUrl || '/notifications',
|
||||
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
|
||||
init() {
|
||||
this.fetchUnread();
|
||||
setInterval(() => this.fetchUnread(), 60000);
|
||||
},
|
||||
async fetchUnread() {
|
||||
try {
|
||||
const res = await fetch(this.unreadUrl, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open) {
|
||||
this.loading = true;
|
||||
this.fetchUnread().finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
async markRead(id) {
|
||||
const url = this.markReadUrl.replace('__ID__', id);
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
},
|
||||
async markAllRead() {
|
||||
try {
|
||||
await fetch(this.markAllReadUrl, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all notifications as read', e);
|
||||
}
|
||||
},
|
||||
getIconBg(icon) {
|
||||
const map = { success: 'bg-green-50', task: 'bg-amber-50' };
|
||||
return map[icon] || 'bg-slate-100';
|
||||
},
|
||||
getIconColor(icon) {
|
||||
const map = { success: 'text-green-600', task: 'text-amber-600' };
|
||||
return map[icon] || 'text-slate-500';
|
||||
},
|
||||
}));
|
||||
|
||||
// Afia — in-app AI assistant slide-over. Opened via $dispatch('afia-open').
|
||||
Alpine.data('afia', (config = {}) => ({
|
||||
open: false,
|
||||
input: '',
|
||||
loading: false,
|
||||
messages: [
|
||||
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
||||
],
|
||||
suggestions: config.suggestions || [],
|
||||
init() {
|
||||
window.addEventListener('afia-open', () => {
|
||||
this.open = true;
|
||||
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
|
||||
});
|
||||
},
|
||||
close() { this.open = false; },
|
||||
useSuggestion(s) { this.input = s; this.send(); },
|
||||
scrollDown() {
|
||||
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
|
||||
},
|
||||
async send() {
|
||||
const text = this.input.trim();
|
||||
if (!text || this.loading) return;
|
||||
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
|
||||
this.messages.push({ role: 'user', text });
|
||||
this.input = '';
|
||||
this.loading = true;
|
||||
this.scrollDown();
|
||||
try {
|
||||
const res = await fetch(config.chatUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (! res.ok) {
|
||||
this.messages.push({ role: 'assistant', text: data.message || data.reply || 'Sorry, I could not respond.' });
|
||||
} else {
|
||||
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
||||
}
|
||||
} catch (e) {
|
||||
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
||||
}
|
||||
this.loading = false;
|
||||
this.scrollDown();
|
||||
},
|
||||
}));
|
||||
|
||||
// Wallet balance peek for the avatar dropdown.
|
||||
Alpine.data('walletWidget', (config = {}) => ({
|
||||
display: '…',
|
||||
async load() {
|
||||
try {
|
||||
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await res.json();
|
||||
this.display = data.available ? data.formatted : 'View wallet';
|
||||
} catch (e) {
|
||||
this.display = 'View wallet';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Deal line-items editor — products/services as quote lines; deal value = subtotal.
|
||||
Alpine.data('dealForm', (initialLines = [], products = []) => ({
|
||||
lines: Array.isArray(initialLines) ? initialLines : [],
|
||||
products: Array.isArray(products) ? products : [],
|
||||
selectedProduct: '',
|
||||
addLine() {
|
||||
this.lines.push({ product_id: '', description: '', quantity: 1, unit_price: '' });
|
||||
},
|
||||
removeLine(index) {
|
||||
this.lines.splice(index, 1);
|
||||
},
|
||||
addProductLine() {
|
||||
const p = this.products.find((x) => String(x.id) === String(this.selectedProduct));
|
||||
if (!p) return;
|
||||
this.lines.push({
|
||||
product_id: p.id,
|
||||
description: p.name || '',
|
||||
quantity: 1,
|
||||
unit_price: (Number(p.unit_price_minor || 0) / 100).toFixed(2),
|
||||
});
|
||||
this.selectedProduct = '';
|
||||
},
|
||||
lineTotal(line) {
|
||||
return (parseFloat(line.quantity) || 0) * (parseFloat(line.unit_price) || 0);
|
||||
},
|
||||
get subtotal() {
|
||||
return this.lines.reduce((sum, line) => sum + this.lineTotal(line), 0);
|
||||
},
|
||||
money(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
}));
|
||||
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
@@ -0,0 +1,98 @@
|
||||
export function registerKioskFlow(Alpine) {
|
||||
Alpine.data('kioskFlow', (config = {}) => ({
|
||||
step: 'select',
|
||||
queueId: null,
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
ticket: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
issueUrl: config.issueUrl,
|
||||
csrf: config.csrf,
|
||||
|
||||
selectQueue(id) {
|
||||
this.queueId = id;
|
||||
this.step = 'details';
|
||||
},
|
||||
|
||||
async issue() {
|
||||
if (! this.queueId) return;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch(this.issueUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
queue_id: this.queueId,
|
||||
customer_name: this.customerName || null,
|
||||
customer_phone: this.customerPhone || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (! res.ok) throw new Error(data.message || 'Could not issue ticket');
|
||||
this.ticket = data.data;
|
||||
this.step = 'ticket';
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Something went wrong';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.step = 'select';
|
||||
this.queueId = null;
|
||||
this.customerName = '';
|
||||
this.customerPhone = '';
|
||||
this.ticket = null;
|
||||
this.error = null;
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('displayBoard', (dataUrl) => ({
|
||||
payload: null,
|
||||
async poll() {
|
||||
try {
|
||||
const res = await fetch(dataUrl, { headers: { Accept: 'application/json' } });
|
||||
this.payload = await res.json();
|
||||
if (this.payload?.announcements?.length && window.speechSynthesis) {
|
||||
const msg = this.payload.announcements[0]?.message;
|
||||
if (msg) {
|
||||
const utter = new SpeechSynthesisUtterance(msg);
|
||||
window.speechSynthesis.speak(utter);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Display poll failed', e);
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.poll();
|
||||
setInterval(() => this.poll(), 5000);
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('mobileTracker', (pollUrl) => ({
|
||||
data: null,
|
||||
ahead: 0,
|
||||
async poll() {
|
||||
try {
|
||||
const res = await fetch(pollUrl, { headers: { Accept: 'application/json' } });
|
||||
const json = await res.json();
|
||||
this.data = json.data;
|
||||
this.ahead = json.customers_ahead ?? 0;
|
||||
} catch (e) {
|
||||
console.error('Tracker poll failed', e);
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.poll();
|
||||
setInterval(() => this.poll(), 10000);
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@php
|
||||
$signedOut = (array) config('signed_out');
|
||||
$logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillfrontdesk-logo.svg');
|
||||
$logoPath = public_path($logo);
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Signed out — {{ $signedOut['title'] ?? config('app.name') }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css'])
|
||||
<meta name="robots" content="noindex">
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-slate-100 px-4 py-10 font-sans antialiased">
|
||||
<div class="w-full max-w-md overflow-hidden rounded-3xl bg-white shadow-xl shadow-slate-200/70">
|
||||
<div class="h-1.5" style="background: linear-gradient(90deg, {{ $signedOut['gradient_from'] ?? '#4f46e5' }} 0%, {{ $signedOut['gradient_to'] ?? '#7c3aed' }} 100%);"></div>
|
||||
|
||||
<div class="px-6 pb-8 pt-7 text-center sm:px-8">
|
||||
<img src="{{ asset($logo) }}?v={{ @filemtime($logoPath) ?: '1' }}"
|
||||
alt="{{ $signedOut['title'] ?? config('app.name') }}"
|
||||
class="mx-auto h-7 w-auto">
|
||||
|
||||
<div @class([
|
||||
'mx-auto mt-6 flex h-14 w-14 items-center justify-center rounded-2xl',
|
||||
$signedOut['icon_wrapper_class'] ?? 'bg-indigo-50 text-indigo-700',
|
||||
])>
|
||||
<svg class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-5 text-xl font-semibold text-slate-900">You’re signed out</h1>
|
||||
<p class="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
{{ $signedOut['description'] ?? ('Your '.($signedOut['title'] ?? config('app.name')).' session has ended. Sign in again to continue.') }}
|
||||
</p>
|
||||
|
||||
<a href="{{ route('sso.connect') }}" class="btn-primary btn-primary-lg mt-7 w-full">
|
||||
Sign in again
|
||||
</a>
|
||||
|
||||
<a href="{{ 'https://'.config('app.platform_domain') }}"
|
||||
class="mt-4 inline-block text-sm font-medium text-slate-500 transition hover:text-slate-800">
|
||||
Go to ladill.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
@php
|
||||
$signedOut = (array) config('signed_out');
|
||||
$logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillfrontdesk-logo.svg');
|
||||
$title = (string) ($signedOut['title'] ?? config('app.name'));
|
||||
$logoPath = public_path($logo);
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign-in problem · {{ $title }}</title>
|
||||
@include('partials.favicon')
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-slate-50 font-sans text-slate-900 antialiased">
|
||||
<div class="mx-auto max-w-md px-6 text-center">
|
||||
<img src="{{ asset($logo) }}?v={{ @filemtime($logoPath) ?: '1' }}" alt="{{ $title }}" class="mx-auto h-8 w-auto">
|
||||
<h1 class="mt-6 text-xl font-semibold">We couldn't sign you in</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Sign-in didn't complete after a few tries. This is usually a temporary
|
||||
issue with the Ladill account service. Please wait a moment and try again.
|
||||
</p>
|
||||
@if ($reason !== '')
|
||||
<p class="mt-3 text-xs text-slate-400">Reference: {{ $reason }}</p>
|
||||
@endif
|
||||
<a href="{{ route('sso.connect', ['interactive' => 1]) }}" class="mt-6 inline-flex rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">Try again</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signing out…</title>
|
||||
@include('partials.favicon')
|
||||
<meta name="robots" content="noindex">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; background: #f8fafc; color: #334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Signing you out of Ladill…</p>
|
||||
<iframe src="{{ $hubUrl }}" hidden width="0" height="0" title=""></iframe>
|
||||
<script>
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== @json($authOrigin)) return;
|
||||
if (event.data && event.data.type === 'ladill:logout:done' && event.data.return) {
|
||||
window.location.replace(event.data.return);
|
||||
}
|
||||
});
|
||||
setTimeout(function () { window.location.replace(@json($return)); }, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
@props(['title' => 'Ladill Queue', 'heading' => null])
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title }} · Ladill Queue</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100 text-slate-900" x-data="{ sidebarOpen: false }">
|
||||
@include('partials.boot-splash')
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-data="{ ready: false }" x-init="requestAnimationFrame(() => ready = true)"
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full transform lg:translate-x-0"
|
||||
:class="{ '!translate-x-0': sidebarOpen, 'transition-transform': ready }">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar', ['heading' => $heading ?? $title])
|
||||
<main class="flex-1 p-4 pb-24 sm:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@php
|
||||
$navUser = auth()->user();
|
||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||
->filter()->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
$navAvatarUrl = $navUser && method_exists($navUser, 'avatarUrl')
|
||||
? $navUser->avatarUrl()
|
||||
: ($navUser?->avatar_url ?? null);
|
||||
@endphp
|
||||
@include('partials.mobile-bottom-nav', [
|
||||
'homeUrl' => route('frontdesk.dashboard'),
|
||||
'homeActive' => request()->routeIs('frontdesk.dashboard'),
|
||||
'searchUrl' => route('frontdesk.visitors.index'),
|
||||
'searchActive' => request()->routeIs('frontdesk.visitors.*'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
|
||||
'avatarUrl' => $navAvatarUrl,
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@endauth
|
||||
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
|
||||
@include('partials.afia')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
@props(['color' => 'slate'])
|
||||
@php
|
||||
$map = [
|
||||
'slate' => 'bg-slate-100 text-slate-700',
|
||||
'indigo' => 'bg-indigo-100 text-indigo-700',
|
||||
'green' => 'bg-green-100 text-green-700',
|
||||
'red' => 'bg-red-100 text-red-700',
|
||||
'amber' => 'bg-amber-100 text-amber-700',
|
||||
'blue' => 'bg-blue-100 text-blue-700',
|
||||
];
|
||||
$classes = $map[$color] ?? $map['slate'];
|
||||
@endphp
|
||||
<span {{ $attributes->merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium $classes"]) }}>
|
||||
{{ $slot }}
|
||||
</span>
|
||||
@@ -0,0 +1,33 @@
|
||||
@props([
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : 'button';
|
||||
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
aria-label="{{ $ariaLabel }}"
|
||||
title="{{ $ariaLabel }}"
|
||||
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
</{{ $tag }}>
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
{{ $attributes->class(['btn-primary hidden lg:inline-flex']) }}
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,37 @@
|
||||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
'type' => 'text',
|
||||
'value' => null,
|
||||
'options' => [],
|
||||
'placeholder' => '',
|
||||
'required' => false,
|
||||
'rows' => 4,
|
||||
])
|
||||
@php
|
||||
$label = $label ?? \Illuminate\Support\Str::headline($name);
|
||||
$current = old($name, $value);
|
||||
$base = 'mt-1 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500';
|
||||
@endphp
|
||||
<div {{ $attributes->only('class') }}>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium text-slate-700">
|
||||
{{ $label }} @if($required)<span class="text-red-500">*</span>@endif
|
||||
</label>
|
||||
|
||||
@if ($type === 'textarea')
|
||||
<textarea id="{{ $name }}" name="{{ $name }}" rows="{{ $rows }}" placeholder="{{ $placeholder }}"
|
||||
class="{{ $base }}">{{ $current }}</textarea>
|
||||
@elseif ($type === 'select')
|
||||
<select id="{{ $name }}" name="{{ $name }}" class="{{ $base }}">
|
||||
@foreach ($options as $optValue => $optLabel)
|
||||
<option value="{{ $optValue }}" @selected((string) $current === (string) $optValue)>{{ $optLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input type="{{ $type }}" id="{{ $name }}" name="{{ $name }}" value="{{ $current }}"
|
||||
placeholder="{{ $placeholder }}" @if($type==='number') step="0.01" min="0" @endif
|
||||
class="{{ $base }}">
|
||||
@endif
|
||||
|
||||
@error($name)<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
])
|
||||
|
||||
@php
|
||||
$maxWidth = [
|
||||
'sm' => 'sm:max-w-sm',
|
||||
'md' => 'sm:max-w-md',
|
||||
'lg' => 'sm:max-w-lg',
|
||||
'xl' => 'sm:max-w-xl',
|
||||
'2xl' => 'sm:max-w-2xl',
|
||||
][$maxWidth];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
show: @js($show),
|
||||
focusables() {
|
||||
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
|
||||
return [...$el.querySelectorAll(selector)]
|
||||
.filter(el => ! el.hasAttribute('disabled'))
|
||||
},
|
||||
firstFocusable() { return this.focusables()[0] },
|
||||
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
|
||||
}"
|
||||
x-init="$watch('show', value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-y-hidden');
|
||||
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
|
||||
} else {
|
||||
setTimeout(() => { if (!show) document.body.classList.remove('overflow-y-hidden'); }, 200);
|
||||
}
|
||||
})"
|
||||
x-on:open-modal.window="String($event.detail) === @js($name) ? show = true : null"
|
||||
data-modal-name="{{ $name }}"
|
||||
x-on:close-modal.window="String($event.detail) === @js($name) ? show = false : null"
|
||||
x-on:close.stop="show = false"
|
||||
x-on:keydown.escape.window="show = false"
|
||||
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
|
||||
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
|
||||
x-show="show"
|
||||
x-cloak
|
||||
x-transition:enter="transition-opacity ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center"
|
||||
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
|
||||
>
|
||||
{{-- Backdrop click to close --}}
|
||||
<div class="absolute inset-0" x-on:click="show = false" data-close-modal="{{ $name }}"></div>
|
||||
|
||||
{{-- Panel: bottom-sheet on mobile, centered card on sm+ --}}
|
||||
<div
|
||||
x-show="show"
|
||||
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:rounded-2xl {{ $maxWidth }} sm:border sm:border-gray-200"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
x-on:click.stop
|
||||
>
|
||||
{{-- Drag handle (mobile only) --}}
|
||||
<div class="flex justify-center pb-1 pt-3 sm:hidden">
|
||||
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
|
||||
{{-- Close button (desktop only) --}}
|
||||
<button @click="show = false" data-close-modal="{{ $name }}" type="button"
|
||||
class="absolute right-3 top-3 z-10 hidden h-8 w-8 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 sm:flex">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body style="margin:0;background:#f1f5f9;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 16px;">
|
||||
<div style="background:#ffffff;border-radius:16px;padding:28px;border:1px solid #e2e8f0;">
|
||||
<div style="white-space:pre-wrap;font-size:14px;line-height:1.6;color:#334155;">{{ $bodyText }}</div>
|
||||
@if (! empty($fromName))
|
||||
<p style="margin-top:24px;font-size:13px;color:#64748b;">— {{ $fromName }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<p style="margin-top:16px;text-align:center;font-size:11px;color:#94a3b8;">Sent via Ladill Frontdesk</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<x-app-layout title="Notifications">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-lg font-semibold text-slate-900">Notifications</h1>
|
||||
|
||||
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@forelse ($notifications as $notification)
|
||||
<a href="{{ $notification->data['url'] ?? '#' }}" class="flex items-start gap-3 border-b border-slate-50 px-5 py-4 last:border-0 hover:bg-slate-50 {{ $notification->read_at ? 'opacity-70' : '' }}">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-500">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900">{{ $notification->data['title'] ?? 'Notification' }}</p>
|
||||
<p class="text-sm text-slate-600">{{ $notification->data['message'] ?? '' }}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ $notification->created_at->diffForHumans() }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-5 py-12 text-center text-sm text-slate-400">No notifications yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $notifications->links() }}</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
@props(['compact' => false])
|
||||
|
||||
<button type="button"
|
||||
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
|
||||
@class([
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
|
||||
'h-9 w-9 px-0 lg:h-auto lg:w-auto lg:px-4 lg:py-2' => $compact,
|
||||
'px-3 py-2 lg:px-4' => ! $compact,
|
||||
])
|
||||
aria-label="Open Afia AI assistant">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
|
||||
</svg>
|
||||
<span @class(['sr-only lg:not-sr-only lg:inline' => $compact, 'hidden sm:inline' => ! $compact])>AI</span>
|
||||
</button>
|
||||
@@ -0,0 +1,106 @@
|
||||
@php
|
||||
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about visitor check-in, kiosks, hosts, badges, devices, or setting up your reception desk…";
|
||||
$afiaSuggestions = [
|
||||
'How do I set up a visitor kiosk?',
|
||||
'How do I check a visitor in?',
|
||||
'Where do I add reception desks?',
|
||||
'How do hosts approve visits?',
|
||||
];
|
||||
@endphp
|
||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ route('frontdesk.ai.chat') }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
greeting: @js($afiaGreeting),
|
||||
suggestions: @js($afiaSuggestions),
|
||||
})" @afia-open.window="open = true; $nextTick(() => $refs.input && $refs.input.focus())">
|
||||
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
|
||||
|
||||
<section x-show="open" x-cloak
|
||||
x-transition:enter="transition transform ease-out duration-200"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition transform ease-in duration-150"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="14" fill="url(#afia-orb)">
|
||||
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
|
||||
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r="1.2" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||
<p class="text-[11px] text-slate-400">Frontdesk assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="{{ ladill_account_url('ai') }}"
|
||||
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="AI usage history on your Ladill account">History</a>
|
||||
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
|
||||
<div class="space-y-3">
|
||||
<template x-for="(m, i) in messages" :key="i">
|
||||
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
|
||||
x-html="m.text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="loading" class="flex justify-start">
|
||||
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
|
||||
<template x-for="s in suggestions" :key="s">
|
||||
<button @click="useSuggestion(s)" :disabled="loading"
|
||||
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
|
||||
<span class="text-indigo-400">→</span><span x-text="s"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
|
||||
<form @submit.prevent="send()" class="flex items-end gap-2">
|
||||
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<button type="submit" :disabled="loading || !input.trim()"
|
||||
class="btn-fab shrink-0 disabled:opacity-40">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes — verify important details.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
@php
|
||||
// Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain.
|
||||
$sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? ''));
|
||||
$icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null;
|
||||
$label = (string) config('app.name', 'Ladill');
|
||||
@endphp
|
||||
<div id="ladill-boot" role="status" aria-live="polite">
|
||||
<div class="lb-wrap">
|
||||
@if ($icon)
|
||||
<img class="lb-logo" src="{{ asset($icon) }}?v={{ @filemtime(public_path($icon)) ?: '1' }}" alt="">
|
||||
@endif
|
||||
<div class="lb-bar"><span></span></div>
|
||||
<div class="lb-title">Loading {{ $label }}…</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#ladill-boot{position:fixed;inset:0;z-index:99999;background:#f8fafc;display:flex;align-items:center;justify-content:center;transition:opacity .35s ease;font-family:'Figtree',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
||||
#ladill-boot .lb-wrap{display:flex;flex-direction:column;align-items:center;gap:24px;width:260px}
|
||||
#ladill-boot .lb-logo{width:64px;height:64px;animation:lb-pulse 1.6s ease-in-out infinite}
|
||||
#ladill-boot .lb-bar{width:100%;height:4px;background:#e2e8f0;border-radius:999px;overflow:hidden}
|
||||
#ladill-boot .lb-bar>span{display:block;height:100%;width:8%;border-radius:999px;background:linear-gradient(90deg,#4f46e5,#7c3aed);animation:lb-fill 1.9s cubic-bezier(.4,0,.2,1) forwards}
|
||||
#ladill-boot .lb-title{font-size:14px;color:#64748b;font-weight:500}
|
||||
@keyframes lb-fill{0%{width:8%}55%{width:68%}100%{width:93%}}
|
||||
@keyframes lb-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.93);opacity:.82}}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
var el=document.getElementById('ladill-boot');
|
||||
if(!el)return;
|
||||
// Show once per browser session; suppress instantly on later navigations (no flash).
|
||||
try{
|
||||
if(sessionStorage.getItem('lb_booted')){el.parentNode.removeChild(el);return;}
|
||||
sessionStorage.setItem('lb_booted','1');
|
||||
}catch(e){}
|
||||
var start=Date.now(),MIN=550;
|
||||
function done(){
|
||||
var wait=Math.max(0,MIN-(Date.now()-start));
|
||||
setTimeout(function(){if(!el)return;el.style.opacity='0';setTimeout(function(){if(el&&el.parentNode)el.parentNode.removeChild(el);},400);},wait);
|
||||
}
|
||||
if(document.readyState==='complete')done();else window.addEventListener('load',done);
|
||||
setTimeout(done,8000); // safety net
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
@php
|
||||
$svgVer = @filemtime(public_path('favicon.svg')) ?: '1';
|
||||
$icoVer = @filemtime(public_path('favicon.ico')) ?: '1';
|
||||
@endphp
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
|
||||
<link rel="alternate icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}" sizes="any">
|
||||
<link rel="shortcut icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
|
||||
<link rel="apple-touch-icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
|
||||
@@ -0,0 +1,34 @@
|
||||
@if (session('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" x-transition class="mb-4">
|
||||
<div class="rounded-lg bg-green-50 border border-green-200 p-4">
|
||||
<p class="text-sm text-green-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 6000)" x-transition class="mb-4">
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('warning'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mb-4">
|
||||
<div class="rounded-lg bg-yellow-50 border border-yellow-200 p-4">
|
||||
<p class="text-sm text-yellow-800">{{ session('warning') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm font-medium text-red-800">Please fix the following:</p>
|
||||
<ul class="mt-1 list-disc pl-5 text-sm text-red-700">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,84 @@
|
||||
@php
|
||||
// Shared Ladill app launcher — IDENTICAL across every app/service.
|
||||
// Driven by config/ladill_launcher.php (fully-extracted apps only) + icons
|
||||
// from public/images/launcher-icons/. Omits the current app (matched by
|
||||
// APP_URL host). To replicate elsewhere, copy this file + the config +
|
||||
// the launcher-icons folder verbatim.
|
||||
$sidebar = (bool) ($sidebar ?? false);
|
||||
$selfHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$launcherApps = array_values(array_filter(
|
||||
config('ladill_launcher.apps', []),
|
||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||
));
|
||||
$launcherOverflows = count($launcherApps) > 15;
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
<div class="relative" x-data="{
|
||||
appsOpen: false,
|
||||
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
|
||||
checkScrollEnd() {
|
||||
const el = this.$refs.appsScroller;
|
||||
if (! el) return;
|
||||
this.showScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 4;
|
||||
},
|
||||
toggleApps() {
|
||||
this.appsOpen = ! this.appsOpen;
|
||||
if (this.appsOpen) {
|
||||
this.$nextTick(() => this.checkScrollEnd());
|
||||
} else {
|
||||
this.showScrollMore = {{ $launcherOverflows ? 'true' : 'false' }};
|
||||
}
|
||||
},
|
||||
}" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
||||
<button type="button" @click="toggleApps()"
|
||||
@class([
|
||||
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
||||
'p-2' => ! $sidebar,
|
||||
'w-full gap-3 px-3 py-2 text-[13px] hover:text-slate-900' => $sidebar,
|
||||
])
|
||||
:class="appsOpen ? 'bg-slate-50 text-slate-900' : ''" aria-label="All apps">
|
||||
<svg @class(['shrink-0', 'h-5 w-5' => ! $sidebar, 'h-[18px] w-[18px]' => $sidebar]) viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 0.5H1.5C0.947715 0.5 0.5 0.947715 0.5 1.5V4.5C0.5 5.05228 0.947715 5.5 1.5 5.5H4.5C5.05228 5.5 5.5 5.05228 5.5 4.5V1.5C5.5 0.947715 5.05228 0.5 4.5 0.5Z"/>
|
||||
<path d="M12.5 0.5H9.5C8.94772 0.5 8.5 0.947715 8.5 1.5V4.5C8.5 5.05228 8.94772 5.5 9.5 5.5H12.5C13.0523 5.5 13.5 5.05228 13.5 4.5V1.5C13.5 0.947715 13.0523 0.5 12.5 0.5Z"/>
|
||||
<path d="M4.5 8.5H1.5C0.947715 8.5 0.5 8.94772 0.5 9.5V12.5C0.5 13.0523 0.947715 13.5 1.5 13.5H4.5C5.05228 13.5 5.5 13.0523 5.5 12.5V9.5C5.5 8.94772 5.05228 8.5 4.5 8.5Z"/>
|
||||
<path d="M12.5 8.5H9.5C8.94772 8.5 8.5 8.94772 8.5 9.5V12.5C8.5 13.0523 8.94772 13.5 9.5 13.5H12.5C13.0523 13.5 13.5 13.0523 13.5 12.5V9.5C13.5 8.94772 13.0523 8.5 12.5 8.5Z"/>
|
||||
</svg>
|
||||
@if ($sidebar)
|
||||
<span class="font-medium">All Ladill apps</span>
|
||||
@endif
|
||||
</button>
|
||||
<div x-show="appsOpen" x-cloak x-transition.origin.top.right
|
||||
@class([
|
||||
'absolute z-50 w-72 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl',
|
||||
'right-0 mt-2' => ! $sidebar,
|
||||
'bottom-full left-0 mb-2' => $sidebar,
|
||||
])>
|
||||
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
|
||||
<div @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
|
||||
<div
|
||||
class="ladill-launcher-apps"
|
||||
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex min-h-[5.25rem] flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
|
||||
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</span>
|
||||
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@if ($launcherOverflows)
|
||||
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,130 @@
|
||||
@php
|
||||
$showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
|
||||
$gridCols = match (true) {
|
||||
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
|
||||
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
|
||||
};
|
||||
$avatarUrl = $avatarUrl ?? null;
|
||||
$initials = $initials ?? 'U';
|
||||
$notificationsUrl = $notificationsUrl ?? '#';
|
||||
$unreadUrl = $unreadUrl ?? null;
|
||||
$profileUrl = $profileUrl ?? '#';
|
||||
$profileName = trim((string) ($profileName ?? ''));
|
||||
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
|
||||
$profileMenuItems = $profileMenuItems ?? [];
|
||||
if ($profileMenuItems === [] && $profileUrl !== '#') {
|
||||
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
|
||||
}
|
||||
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
|
||||
@endphp
|
||||
<div x-data="{ profileOpen: false }" class="lg:hidden">
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="grid h-16 {{ $gridCols }}">
|
||||
<a href="{{ $homeUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
|
||||
<span class="text-[10px] font-medium">Home</span>
|
||||
</a>
|
||||
|
||||
@if ($showSearch)
|
||||
<a href="{{ $searchUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<span class="text-[10px] font-medium">Search</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if (!empty($centerCompose))
|
||||
<div class="flex items-center justify-center">
|
||||
<button type="button"
|
||||
@click="$dispatch('compose-open')"
|
||||
class="btn-fab -translate-y-1 ring-4 ring-white shadow-md"
|
||||
aria-label="Compose">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ $notificationsUrl }}"
|
||||
@if ($unreadUrl)
|
||||
x-data="{
|
||||
unread: 0,
|
||||
init() {
|
||||
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
|
||||
},
|
||||
}"
|
||||
@endif
|
||||
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@if ($unreadUrl)
|
||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Notifications</span>
|
||||
</a>
|
||||
|
||||
<button type="button"
|
||||
@click="profileOpen = true"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
|
||||
:class="profileOpen ? 'text-indigo-600' : ''"
|
||||
aria-label="Open profile menu">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
|
||||
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
|
||||
<div x-show="profileOpen" x-transition.opacity.duration.200ms
|
||||
@click="profileOpen = false"
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
||||
|
||||
<div x-show="profileOpen"
|
||||
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full"
|
||||
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||
@click.outside="profileOpen = false">
|
||||
<div class="flex justify-center pt-3">
|
||||
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
@if ($profileName !== '' || $profileSubtitle !== '')
|
||||
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
@if ($profileName !== '')
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
|
||||
@endif
|
||||
@if ($profileSubtitle !== '')
|
||||
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('partials.user-profile-menu', [
|
||||
'items' => $profileMenuItems,
|
||||
'variant' => 'sheet',
|
||||
'onNavigate' => 'profileOpen = false',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$variant = $variant ?? 'primary';
|
||||
$type = $type ?? 'submit';
|
||||
$tag = ! empty($href) ? 'a' : 'button';
|
||||
$ariaLabel = $ariaLabel ?? $label ?? '';
|
||||
$desktopLabel = $desktopLabel ?? $label ?? '';
|
||||
$showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary');
|
||||
|
||||
$mobileClass = match ($variant) {
|
||||
'primary' => 'btn-fab h-10 w-10 lg:hidden',
|
||||
'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden',
|
||||
'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden',
|
||||
'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden',
|
||||
default => 'btn-fab h-10 w-10 lg:hidden',
|
||||
};
|
||||
|
||||
$desktopClass = match ($variant) {
|
||||
'primary' => 'btn-primary hidden items-center lg:inline-flex',
|
||||
'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex',
|
||||
'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex',
|
||||
'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex',
|
||||
default => 'btn-primary hidden items-center lg:inline-flex',
|
||||
};
|
||||
|
||||
$extraClass = $class ?? '';
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($tag === 'a') href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
aria-label="{{ $ariaLabel }}"
|
||||
title="{{ $ariaLabel }}"
|
||||
@if (! empty($attributes)) {!! $attributes !!} @endif
|
||||
class="{{ trim($mobileClass.' '.$extraClass) }}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
</{{ $tag }}>
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($tag === 'a') href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
@if (! empty($attributes)) {!! $attributes !!} @endif
|
||||
class="{{ trim($desktopClass.' '.$extraClass) }}"
|
||||
>
|
||||
@if ($showDesktopIcon)
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $desktopLabel }}
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,27 @@
|
||||
@php
|
||||
$icon = $icon ?? 'arrow';
|
||||
$desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700';
|
||||
$label = $label ?? '';
|
||||
@endphp
|
||||
|
||||
<a href="{{ $href }}"
|
||||
aria-label="{{ $label }}"
|
||||
title="{{ $label }}"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 transition hover:bg-indigo-100 lg:h-auto lg:w-auto lg:rounded-none lg:bg-transparent lg:ring-0 lg:hover:bg-transparent">
|
||||
<span class="lg:hidden">
|
||||
@if ($icon === 'shield')
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
|
||||
</svg>
|
||||
@elseif ($icon === 'calendar')
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
|
||||
</svg>
|
||||
@endif
|
||||
</span>
|
||||
<span class="hidden lg:inline {{ $desktopClass }}">{{ $label }}</span>
|
||||
</a>
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$title = $mobileTopbarTitle ?? 'Ladill';
|
||||
@endphp
|
||||
<div class="min-w-0 flex-1 lg:hidden">
|
||||
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
|
||||
</div>
|
||||
@@ -0,0 +1,97 @@
|
||||
{{-- In-app notification bell + dropdown (scoped to this app). --}}
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2.5 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.7" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center">
|
||||
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="getIconBg(notification.icon)">
|
||||
<template x-if="notification.icon === 'success'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'task'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="!['success','task'].includes(notification.icon)">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
|
||||
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<a href="{{ route('qms.dashboard') }}" class="flex items-center">
|
||||
<img src="{{ asset('images/logo/ladillqueue-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillqueue-logo.svg')) ?: '1' }}" alt="Ladill Queue" class="h-6 w-auto">
|
||||
</a>
|
||||
</div>
|
||||
@php
|
||||
$member = auth()->user() ? app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()) : null;
|
||||
$permissions = app(\App\Services\Qms\QmsPermissions::class);
|
||||
$nav = [
|
||||
['name' => 'Dashboard', 'route' => route('qms.dashboard'), 'active' => request()->routeIs('qms.dashboard'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
|
||||
['name' => 'Live board', 'route' => route('qms.board.index'), 'active' => request()->routeIs('qms.board.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'],
|
||||
];
|
||||
if ($permissions->can($member, 'queues.view')) {
|
||||
$nav[] = ['name' => 'Queues', 'route' => route('qms.queues.index'), 'active' => request()->routeIs('qms.queues.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.25 4.5h13.5a.75.75 0 0 1 .75.75v15.75a.75.75 0 0 1-.75.75H5.25a.75.75 0 0 1-.75-.75V5.25a.75.75 0 0 1 .75-.75Z" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'tickets.view')) {
|
||||
$nav[] = ['name' => 'Tickets', 'route' => route('qms.tickets.index'), 'active' => request()->routeIs('qms.tickets.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'counters.view')) {
|
||||
$nav[] = ['name' => 'Counters', 'route' => route('qms.counters.index'), 'active' => request()->routeIs('qms.counters.*') || request()->routeIs('qms.console.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m-1.5 3h1.5m3-9H15m-1.5 3H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'displays.view')) {
|
||||
$nav[] = ['name' => 'Displays', 'route' => route('qms.displays.index'), 'active' => request()->routeIs('qms.displays.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />'];
|
||||
$nav[] = ['name' => 'Devices', 'route' => route('qms.devices.index'), 'active' => request()->routeIs('qms.devices.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'appointments.view')) {
|
||||
$nav[] = ['name' => 'Appointments', 'route' => route('qms.appointments.index'), 'active' => request()->routeIs('qms.appointments.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'workflows.view')) {
|
||||
$nav[] = ['name' => 'Workflows', 'route' => route('qms.workflows.index'), 'active' => request()->routeIs('qms.workflows.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'reports.view')) {
|
||||
$nav[] = ['name' => 'Analytics', 'route' => route('qms.analytics.index'), 'active' => request()->routeIs('qms.analytics.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'];
|
||||
$nav[] = ['name' => 'Reports', 'route' => route('qms.reports.index'), 'active' => request()->routeIs('qms.reports.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'feedback.view')) {
|
||||
$nav[] = ['name' => 'Feedback', 'route' => route('qms.feedback.admin'), 'active' => request()->routeIs('qms.feedback.admin'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />'];
|
||||
}
|
||||
$adminItems = [];
|
||||
if ($permissions->can($member, 'admin.branches.view')) {
|
||||
$adminItems[] = ['name' => 'Branches', 'route' => route('qms.branches.index'), 'active' => request()->routeIs('qms.branches.*')];
|
||||
}
|
||||
if ($permissions->can($member, 'admin.departments.view')) {
|
||||
$adminItems[] = ['name' => 'Departments', 'route' => route('qms.departments.index'), 'active' => request()->routeIs('qms.departments.*')];
|
||||
}
|
||||
if ($permissions->can($member, 'admin.members.view')) {
|
||||
$adminItems[] = ['name' => 'Team', 'route' => route('qms.members.index'), 'active' => request()->routeIs('qms.members.*')];
|
||||
}
|
||||
if ($permissions->can($member, 'audit.view')) {
|
||||
$adminItems[] = ['name' => 'Audit log', 'route' => route('qms.audit.index'), 'active' => request()->routeIs('qms.audit.*')];
|
||||
}
|
||||
if ($permissions->can($member, 'settings.view')) {
|
||||
$adminItems[] = ['name' => 'Settings', 'route' => route('qms.settings.edit'), 'active' => request()->routeIs('qms.settings.*')];
|
||||
}
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach ($nav as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold dark:bg-indigo-950 dark:text-indigo-300' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@if (!empty($adminItems))
|
||||
<div class="pt-4 pb-1 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Administration</div>
|
||||
@foreach ($adminItems as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold dark:bg-indigo-950 dark:text-indigo-300' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800' }}">
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- Account switcher (desktop) — only when the user belongs to more than one account. --}}
|
||||
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
|
||||
<div x-data="{ accountSwitcherOpen: false }" @click.outside="accountSwitcherOpen = false" class="relative hidden lg:block">
|
||||
<button type="button" @click="accountSwitcherOpen = !accountSwitcherOpen"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<span class="max-w-[140px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
|
||||
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="accountSwitcherOpen" x-cloak x-transition
|
||||
class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
|
||||
@foreach ($accessibleAccounts as $acctOption)
|
||||
<form method="POST" action="{{ route('account.switch') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="account" value="{{ $acctOption->id }}">
|
||||
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
|
||||
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
|
||||
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600">✓</span>@endif
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,45 @@
|
||||
{{--
|
||||
Top-right header widgets:
|
||||
Mobile — Afia + launcher only (profile/notifications live in bottom nav).
|
||||
Desktop — Afia → notifications → launcher → divider → avatar dropdown.
|
||||
--}}
|
||||
@php
|
||||
$topbarUser = $user ?? auth()->user();
|
||||
$initials = collect(explode(' ', trim((string) $topbarUser?->name)))
|
||||
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
|
||||
$avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl')
|
||||
? $topbarUser->avatarUrl()
|
||||
: ($topbarUser?->avatar_url ?? null);
|
||||
$showUserHeader = $showUser ?? true;
|
||||
@endphp
|
||||
|
||||
@includeIf('partials.afia-button', ['compact' => true])
|
||||
|
||||
@includeIf('partials.notification-dropdown')
|
||||
|
||||
@include('partials.launcher')
|
||||
|
||||
@includeIf('partials.topbar-widgets-mid')
|
||||
|
||||
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
|
||||
|
||||
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="profileOpen = false">
|
||||
<button type="button" @click="profileOpen = !profileOpen"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 transition hover:bg-slate-50">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ $initials !== '' ? $initials : 'U' }}</span>
|
||||
@endif
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
|
||||
</button>
|
||||
|
||||
<div x-show="profileOpen" x-cloak x-transition
|
||||
class="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
@include('partials.user-profile-menu', [
|
||||
'items' => \App\Support\UserProfileMenu::items($topbarUser),
|
||||
'user' => $topbarUser,
|
||||
'showUser' => $showUserHeader,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$initials = collect(explode(' ', trim((string) $user?->name)))
|
||||
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
|
||||
@endphp
|
||||
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-4 sm:px-6">
|
||||
<div class="flex flex-1 items-center gap-3 min-w-0">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700" aria-label="Menu">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- Mobile: app name --}}
|
||||
@include('partials.mobile-topbar-title')
|
||||
|
||||
{{-- Desktop: search (visitors) --}}
|
||||
<form method="GET" action="{{ route('frontdesk.visitors.index') }}"
|
||||
class="relative hidden w-full max-w-md lg:block"
|
||||
x-data
|
||||
@keydown.window="if ($event.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes($event.target.tagName)) { $event.preventDefault(); $refs.search.focus(); }">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input x-ref="search" type="search" name="q" value="{{ request('q') }}"
|
||||
placeholder="Search visitors…"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@includeIf('partials.topbar-widgets-prepend')
|
||||
@include('partials.topbar-desktop-widgets', ['user' => $user, 'showUser' => true])
|
||||
@includeIf('partials.topbar-widgets-append')
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,73 @@
|
||||
@props([
|
||||
'items' => [],
|
||||
'user' => null,
|
||||
'showUser' => false,
|
||||
'variant' => 'dropdown',
|
||||
'onNavigate' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$linkClass = match ($variant) {
|
||||
'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800',
|
||||
'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100',
|
||||
default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100',
|
||||
};
|
||||
|
||||
$logoutClass = match ($variant) {
|
||||
'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40',
|
||||
'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50',
|
||||
default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50',
|
||||
};
|
||||
|
||||
$logoutWrapperClass = match ($variant) {
|
||||
'sheet' => 'border-t border-slate-100 pt-2',
|
||||
'dark' => '',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$dividerClass = match ($variant) {
|
||||
'dark' => 'my-1 border-t border-slate-800',
|
||||
default => 'my-1 border-t border-slate-100',
|
||||
};
|
||||
|
||||
$containerClass = match ($variant) {
|
||||
'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1',
|
||||
'sheet' => 'space-y-1 p-2 pb-4',
|
||||
default => 'p-1',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->merge(['class' => $containerClass]) }}>
|
||||
@if ($showUser && $user)
|
||||
<div class="px-3 py-2">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $user->name ?? 'Your account' }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ $user->email }}</p>
|
||||
</div>
|
||||
<div class="{{ $dividerClass }}"></div>
|
||||
@endif
|
||||
|
||||
@foreach ($items as $item)
|
||||
@if (($item['type'] ?? 'link') === 'link')
|
||||
<a href="{{ $item['href'] }}"
|
||||
class="{{ $linkClass }}"
|
||||
@if ($onNavigate) @click="{{ $onNavigate }}" @endif>
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@elseif (($item['type'] ?? '') === 'wallet')
|
||||
@includeIf('partials.wallet-widget', [
|
||||
'onNavigate' => $onNavigate,
|
||||
'class' => $variant === 'sheet' ? 'mx-2' : 'mx-1',
|
||||
])
|
||||
@elseif (($item['type'] ?? '') === 'logout')
|
||||
@if ($variant !== 'sheet')
|
||||
<div class="{{ $dividerClass }}"></div>
|
||||
@endif
|
||||
<form method="POST" action="{{ $item['action'] }}" class="{{ $logoutWrapperClass }}">
|
||||
@csrf
|
||||
<button type="submit" class="{{ $logoutClass }}">
|
||||
{{ $item['label'] }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
@props(['openOnLoad' => false, 'suggested' => 10, 'min' => 1, 'returnUrl' => null])
|
||||
|
||||
<x-modal name="wallet-topup" :show="$openOnLoad" maxWidth="md">
|
||||
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Top up your wallet</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Add funds to your Ladill wallet to continue.</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('frontdesk.wallet') }}" method="GET" class="p-5 sm:p-6">
|
||||
@csrf
|
||||
<input type="hidden" name="return_url" value="{{ $returnUrl ?? url()->current() }}">
|
||||
|
||||
<label for="topup_amount" class="text-sm font-medium text-slate-700">Amount (GHS)</label>
|
||||
<input type="number" id="topup_amount" name="amount" min="{{ $min }}" max="5000" step="0.01" value="{{ $suggested }}" required
|
||||
class="mt-1.5 block w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400">
|
||||
<p class="mt-1.5 text-xs text-slate-500">You'll be taken to Paystack to pay securely; funds land in your Ladill wallet.</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="$dispatch('close-modal', 'wallet-topup')"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">Cancel</button>
|
||||
<button type="submit" class="btn-primary w-full sm:w-auto">Add funds</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,30 @@
|
||||
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
|
||||
@php
|
||||
$onNavigate = $onNavigate ?? null;
|
||||
$class = trim((string) ($class ?? 'mx-1'));
|
||||
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
|
||||
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
|
||||
$walletUrl = \Illuminate\Support\Facades\Route::has('user.wallet.index')
|
||||
? route('user.wallet.index')
|
||||
: (function_exists('ladill_account_url') ? ladill_account_url('/wallet') : '#');
|
||||
@endphp
|
||||
@if ($balanceUrl)
|
||||
<a href="{{ $walletUrl }}"
|
||||
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
|
||||
@wallet-balance-refresh.window="load()"
|
||||
@if ($onNavigate) @click="{{ $onNavigate }}" @endif
|
||||
class="{{ $class }} flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-gradient-to-r from-indigo-50 to-slate-50 px-3 py-2.5 transition hover:border-indigo-200 hover:from-indigo-100/70">
|
||||
<span class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 1 3 7.5m18 4.5v3.75A2.25 2.25 0 0 1 18.75 18H5.25A2.25 2.25 0 0 1 3 15.75V7.5m18 4.5h-3.75a1.5 1.5 0 0 0 0 3H21M3 7.5A2.25 2.25 0 0 1 5.25 5.25h11.25A2.25 2.25 0 0 1 18.75 7.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[11px] font-medium text-slate-500">Wallet balance</span>
|
||||
<span class="block truncate text-sm font-semibold text-slate-900" x-text="display"></span>
|
||||
</span>
|
||||
</span>
|
||||
<svg class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
@@ -0,0 +1,13 @@
|
||||
<x-app-layout title="New branch">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">New branch</h1>
|
||||
<form method="POST" action="{{ route('qms.branches.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Code</label><input name="code" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Address</label><input name="address" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Phone</label><input name="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<button type="submit" class="btn-primary w-full">Create branch</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,14 @@
|
||||
<x-app-layout title="Edit branch">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Edit branch</h1>
|
||||
<form method="POST" action="{{ route('qms.branches.update', $branch) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $branch->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Code</label><input name="code" value="{{ $branch->code }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Address</label><input name="address" value="{{ $branch->address }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Phone</label><input name="phone" value="{{ $branch->phone }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($branch->is_active)> Active</label>
|
||||
<button type="submit" class="btn-primary w-full">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-app-layout title="Branches">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Branches</h1>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'admin.branches.manage'))
|
||||
<a href="{{ route('qms.branches.create') }}" class="btn-primary">New branch</a>
|
||||
@endif
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Code</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Departments</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach ($branches as $branch)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium">{{ $branch->name }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $branch->code ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $branch->departments_count }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $branch->is_active ? 'Active' : 'Inactive' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="{{ route('qms.branches.edit', $branch) }}" class="text-sm text-indigo-600">Edit</a></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,16 @@
|
||||
<x-app-layout title="New department">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">New department</h1>
|
||||
<form method="POST" action="{{ route('qms.departments.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach ($branches as $b)<option value="{{ $b->id }}">{{ $b->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Type</label>
|
||||
<select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach ($types as $v => $l)<option value="{{ $v }}">{{ $l }}</option>@endforeach</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,14 @@
|
||||
<x-app-layout title="Edit department">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Edit department</h1>
|
||||
<form method="POST" action="{{ route('qms.departments.update', $department) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $department->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Type</label>
|
||||
<select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach ($types as $v => $l)<option value="{{ $v }}" @selected($department->type === $v)>{{ $l }}</option>@endforeach</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($department->is_active)> Active</label>
|
||||
<button type="submit" class="btn-primary w-full">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-app-layout title="Departments">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Departments</h1>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'admin.departments.manage'))
|
||||
<a href="{{ route('qms.departments.create') }}" class="btn-primary">New department</a>
|
||||
@endif
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Branch</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Type</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@forelse ($departments as $department)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm">{{ $department->name }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $department->branch?->name }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $types[$department->type] ?? $department->type }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="{{ route('qms.departments.edit', $department) }}" class="text-sm text-indigo-600">Edit</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-6 py-12 text-center text-sm text-slate-500">No departments.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,16 @@
|
||||
<x-app-layout title="Add member">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Add team member</h1>
|
||||
<form method="POST" action="{{ route('qms.members.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Platform user ref (public_id)</label><input name="user_ref" required class="mt-1 w-full rounded-lg border-slate-300 text-sm font-mono"></div>
|
||||
<div><label class="block text-sm font-medium">Role</label>
|
||||
<select name="role" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach ($roles as $v => $l)<option value="{{ $v }}">{{ $l }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Branch scope (optional)</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"><option value="">Organization-wide</option>@foreach ($branches as $b)<option value="{{ $b->id }}">{{ $b->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Save member</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,33 @@
|
||||
<x-app-layout title="Team">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Team members</h1>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'admin.members.manage'))
|
||||
<a href="{{ route('qms.members.create') }}" class="btn-primary">Add member</a>
|
||||
@endif
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">User ref</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Role</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Branch</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach ($members as $member)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-mono text-xs">{{ $member->user_ref }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $roles[$member->role] ?? $member->role }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $member->branch?->name ?? 'All' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($member->user_ref !== auth()->user()->public_id)
|
||||
<form method="POST" action="{{ route('qms.members.destroy', $member) }}">@csrf @method('DELETE')<button class="text-xs text-red-600">Remove</button></form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-app-layout title="Analytics">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Analytics</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ([
|
||||
'waiting' => 'Waiting now',
|
||||
'serving' => 'Being served',
|
||||
'served_today' => 'Served today',
|
||||
'no_shows_today' => 'No-shows today',
|
||||
'avg_wait_seconds' => 'Avg wait (sec)',
|
||||
'appointments_today' => 'Appointments today',
|
||||
'feedback_avg_30d' => 'Avg rating (30d)',
|
||||
] as $key => $label)
|
||||
<div class="rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">{{ $label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ $overview[$key] ?? 0 }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold uppercase text-slate-500">14-day trend</h2>
|
||||
<table class="mt-4 w-full text-sm">
|
||||
<thead><tr><th class="text-left py-1">Date</th><th class="text-right">Issued</th><th class="text-right">Completed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($trend as $day)
|
||||
<tr class="border-t"><td class="py-1">{{ $day['date'] }}</td><td class="text-right">{{ $day['issued'] }}</td><td class="text-right">{{ $day['completed'] }}</td></tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold uppercase text-slate-500">Busiest queues</h2>
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
@forelse ($overview['busiest_queues'] ?? [] as $q)
|
||||
<li class="flex justify-between"><span>{{ $q['name'] }}</span><span class="font-medium">{{ $q['waiting'] }} waiting</span></li>
|
||||
@empty
|
||||
<li class="text-slate-500">No active queues.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-app-layout title="New appointment">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Schedule appointment</h1>
|
||||
<form method="POST" action="{{ route('qms.appointments.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Queue (optional)</label>
|
||||
<select name="service_queue_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Auto-select at check-in</option>
|
||||
@foreach ($queues as $queue)
|
||||
<option value="{{ $queue->id }}">{{ $queue->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Customer name</label>
|
||||
<input name="customer_name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Phone</label>
|
||||
<input name="customer_phone" type="tel" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Email</label>
|
||||
<input name="customer_email" type="email" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Scheduled at</label>
|
||||
<input name="scheduled_at" type="datetime-local" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Save appointment</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-app-layout title="Edit appointment">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Edit appointment</h1>
|
||||
<form method="POST" action="{{ route('qms.appointments.update', $appointment) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($branches as $b)<option value="{{ $b->id }}" @selected($appointment->branch_id==$b->id)>{{ $b->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Queue</label>
|
||||
<select name="service_queue_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"><option value="">Auto</option>@foreach($queues as $q)<option value="{{ $q->id }}" @selected($appointment->service_queue_id==$q->id)>{{ $q->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Customer</label><input name="customer_name" value="{{ $appointment->customer_name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Phone</label><input name="customer_phone" value="{{ $appointment->customer_phone }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Scheduled at</label><input type="datetime-local" name="scheduled_at" value="{{ $appointment->scheduled_at->format('Y-m-d\TH:i') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<button type="submit" class="btn-primary w-full">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-app-layout title="Appointments">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Appointments</h1>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Schedule and check in pre-booked customers.</p>
|
||||
</div>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'appointments.manage'))
|
||||
<a href="{{ route('qms.appointments.create') }}" class="btn-primary">New appointment</a>
|
||||
@endif
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Customer</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Scheduled</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queue</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
@forelse ($appointments as $appointment)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm">{{ $appointment->customer_name }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $appointment->scheduled_at->format('M j, H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $appointment->serviceQueue?->name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm capitalize">{{ str_replace('_', ' ', $appointment->status) }}</td>
|
||||
<td class="px-4 py-3 text-right text-sm">
|
||||
@if ($appointment->status === 'scheduled')
|
||||
<a href="{{ route('qms.appointments.edit', $appointment) }}" class="text-indigo-600">Edit</a>
|
||||
<form method="POST" action="{{ route('qms.appointments.check-in', $appointment) }}" class="inline ml-2">@csrf<button class="text-indigo-600">Check in</button></form>
|
||||
<form method="POST" action="{{ route('qms.appointments.cancel', $appointment) }}" class="inline ml-2">@csrf<button class="text-amber-600">Cancel</button></form>
|
||||
@elseif ($appointment->ticket)
|
||||
<a href="{{ route('qms.tickets.show', $appointment->ticket) }}" class="text-indigo-600">View ticket</a>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">No appointments yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $appointments->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<x-app-layout title="Audit log">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Audit log</h1>
|
||||
@if ($canExport)
|
||||
<a href="{{ route('qms.audit.export', request()->query()) }}" class="btn-primary">Export CSV</a>
|
||||
@endif
|
||||
</div>
|
||||
<form method="GET" class="mb-4 flex flex-wrap gap-3">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search…" class="rounded-lg border-slate-300 text-sm">
|
||||
<select name="action" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All actions</option>
|
||||
@foreach ($actions as $key => $label)
|
||||
<option value="{{ $key }}" @selected(request('action') === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-slate-100 px-4 py-2 text-sm">Filter</button>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Time</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Action</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Actor</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach ($logs as $log)
|
||||
<tr>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ $log->created_at?->format('M j, H:i') }}</td>
|
||||
<td class="px-4 py-3">{{ $actions[$log->action] ?? $log->action }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $log->actor_ref ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $logs->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,51 @@
|
||||
<x-app-layout title="Live board" x-data="{}" x-init="setInterval(() => location.reload(), 15000)">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Live queue board</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">Auto-refreshes every 15 seconds</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 grid gap-4 md:grid-cols-3">
|
||||
@foreach ($queues as $queue)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background: {{ $queue->color }}"></span>
|
||||
<h2 class="font-semibold">{{ $queue->name }}</h2>
|
||||
@if ($queue->is_paused)
|
||||
<span class="text-xs text-amber-600">Paused</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-2 text-3xl font-bold">{{ $waiting->where('service_queue_id', $queue->id)->count() }}</p>
|
||||
<p class="text-sm text-slate-500">waiting</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="border-b border-slate-200 px-4 py-3 font-semibold dark:border-slate-700">Now serving</h2>
|
||||
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
@forelse ($serving as $ticket)
|
||||
<li class="flex justify-between px-4 py-3">
|
||||
<span class="font-mono text-lg font-bold">{{ $ticket->ticket_number }}</span>
|
||||
<span>{{ $ticket->counter?->name }} · {{ $ticket->serviceQueue?->name }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-6 text-sm text-slate-500">No active tickets</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="border-b border-slate-200 px-4 py-3 font-semibold dark:border-slate-700">Waiting</h2>
|
||||
<ul class="max-h-96 divide-y divide-slate-200 overflow-y-auto dark:divide-slate-700">
|
||||
@forelse ($waiting as $ticket)
|
||||
<li class="flex justify-between px-4 py-2 text-sm">
|
||||
<span class="font-mono font-semibold">{{ $ticket->ticket_number }}</span>
|
||||
<span class="text-slate-500">{{ $ticket->serviceQueue?->name }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-6 text-sm text-slate-500">Queue is empty</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<x-app-layout title="Staff console · {{ $counter->name }}">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ $counter->name }}</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">{{ $counter->branch?->name }} · Status: {{ config('qms.counter_statuses.'.$counter->status, $counter->status) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="available"><button class="btn-secondary">Available</button></form>
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="offline"><button class="btn-secondary">Go offline</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($currentTicket)
|
||||
<div class="mb-8 rounded-2xl border-2 border-indigo-200 bg-indigo-50 p-6 dark:border-indigo-800 dark:bg-indigo-950">
|
||||
<p class="text-sm font-medium text-indigo-700 dark:text-indigo-300">Current customer</p>
|
||||
<p class="mt-1 text-4xl font-bold text-indigo-900 dark:text-white">{{ $currentTicket->ticket_number }}</p>
|
||||
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $currentTicket->serviceQueue?->name }} · {{ $currentTicket->customer_name }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach (['recall' => 'Recall', 'start' => 'Start serving', 'hold' => 'Hold', 'skip' => 'Skip', 'complete' => 'Complete', 'no_show' => 'No show'] as $action => $label)
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="{{ $action }}">
|
||||
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
||||
<button type="submit" class="btn-secondary text-sm">{{ $label }}</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($allQueues->count() > 1)
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 flex flex-wrap items-end gap-2">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="transfer">
|
||||
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
||||
<select name="to_queue_id" class="rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($allQueues as $q)
|
||||
@if ($q->id !== $currentTicket->service_queue_id)
|
||||
<option value="{{ $q->id }}">{{ $q->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" name="reason" placeholder="Reason" class="rounded-lg border-slate-300 text-sm">
|
||||
<button type="submit" class="btn-secondary text-sm">Transfer</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
@foreach ($queues as $queue)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-slate-900 dark:text-white">{{ $queue->name }}</h2>
|
||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="call_next">
|
||||
<input type="hidden" name="queue_id" value="{{ $queue->id }}">
|
||||
<button type="submit" class="btn-primary text-sm">Call next</button>
|
||||
</form>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
@forelse ($waitingByQueue[$queue->id] ?? [] as $ticket)
|
||||
<li class="flex justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm dark:bg-slate-800">
|
||||
<span class="font-mono font-semibold">{{ $ticket->ticket_number }}</span>
|
||||
<span class="text-slate-500">{{ $ticket->customer_name ?? 'Walk-in' }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-500">No one waiting</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-app-layout title="New counter">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<form method="POST" action="{{ route('qms.counters.store') }}" class="space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Code</label><input name="code" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($branches as $branch)<option value="{{ $branch->id }}">{{ $branch->name }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Assigned queues</label>
|
||||
@foreach ($queues as $queue)
|
||||
<label class="mt-2 flex items-center gap-2 text-sm"><input type="checkbox" name="queue_ids[]" value="{{ $queue->id }}"> {{ $queue->name }}</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Create counter</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-app-layout title="Edit counter">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Edit counter</h1>
|
||||
<form method="POST" action="{{ route('qms.counters.update', $counter) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $counter->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Code</label><input name="code" value="{{ $counter->code }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($branches as $b)<option value="{{ $b->id }}" @selected($counter->branch_id==$b->id)>{{ $b->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Queues</label>
|
||||
<select name="queue_ids[]" multiple class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($queues as $q)<option value="{{ $q->id }}" @selected($counter->serviceQueues->contains('id',$q->id))>{{ $q->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($counter->is_active)> Active</label>
|
||||
<button type="submit" class="btn-primary w-full">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-app-layout title="Counters">
|
||||
<div class="mb-6 flex justify-between">
|
||||
<h1 class="text-2xl font-semibold">Counters</h1>
|
||||
<a href="{{ route('qms.counters.create') }}" class="btn-primary">New counter</a>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach ($counters as $counter)
|
||||
<div class="rounded-2xl border bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="flex justify-between">
|
||||
<h2 class="font-semibold">{{ $counter->name }}</h2>
|
||||
<span class="text-sm capitalize">{{ $counter->status }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }}</p>
|
||||
<p class="mt-2 text-sm">Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}</p>
|
||||
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary mt-4 inline-block text-sm">Open console</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-4">{{ $counters->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,14 @@
|
||||
<x-app-layout :title="$counter->name">
|
||||
<div class="mb-6 flex justify-between">
|
||||
<div>
|
||||
<a href="{{ route('qms.counters.index') }}" class="text-sm text-indigo-600">← Counters</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{{ $counter->name }}</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('qms.counters.edit', $counter) }}" class="btn-secondary">Edit</a>
|
||||
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary">Open console</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600">Status: {{ $counter->status }} · {{ $counter->branch?->name }}</p>
|
||||
<p class="mt-2 text-sm">Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}</p>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,130 @@
|
||||
<x-app-layout title="Dashboard">
|
||||
@php
|
||||
$operationalCards = [
|
||||
[
|
||||
'label' => 'Waiting now',
|
||||
'value' => number_format($operational['waiting']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />',
|
||||
],
|
||||
[
|
||||
'label' => 'Being served',
|
||||
'value' => number_format($operational['serving']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />',
|
||||
],
|
||||
[
|
||||
'label' => 'Served today',
|
||||
'value' => number_format($operational['served_today']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />',
|
||||
'valueClass' => 'text-emerald-700',
|
||||
],
|
||||
[
|
||||
'label' => 'Avg wait',
|
||||
'value' => $operational['avg_wait_seconds'] > 0
|
||||
? ceil($operational['avg_wait_seconds'] / 60).' mins'
|
||||
: '—',
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />',
|
||||
],
|
||||
[
|
||||
'label' => 'No-shows today',
|
||||
'value' => number_format($operational['no_shows_today']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />',
|
||||
],
|
||||
];
|
||||
|
||||
$organizationCards = [
|
||||
[
|
||||
'label' => 'Active branches',
|
||||
'value' => number_format($orgStats['branches']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m-1.5 3h1.5m3-9H15m-1.5 3H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />',
|
||||
],
|
||||
[
|
||||
'label' => 'Service queues',
|
||||
'value' => number_format($orgStats['queues']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.25 4.5h13.5a.75.75 0 0 1 .75.75v15.75a.75.75 0 0 1-.75.75H5.25a.75.75 0 0 1-.75-.75V5.25a.75.75 0 0 1 .75-.75Z" />',
|
||||
],
|
||||
[
|
||||
'label' => 'Team members',
|
||||
'value' => number_format($orgStats['team_members']),
|
||||
'href' => route('qms.queues.index'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ $organization->name }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Real-time queue operations overview</p>
|
||||
</div>
|
||||
@if(auth()->user() && app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'queues.manage'))
|
||||
<a href="{{ route('qms.queues.create') }}" class="btn-primary">New queue</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Live operations</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
@foreach ($operationalCards as $card)
|
||||
<a href="{{ $card['href'] }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">{{ $card['label'] }}</p>
|
||||
<svg class="h-5 w-5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $card['icon'] !!}</svg>
|
||||
</div>
|
||||
<p class="mt-2 text-2xl font-semibold {{ $card['valueClass'] ?? 'text-slate-900 dark:text-white' }}">{{ $card['value'] }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Organization</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
@foreach ($organizationCards as $card)
|
||||
<a href="{{ $card['href'] }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">{{ $card['label'] }}</p>
|
||||
<svg class="h-5 w-5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $card['icon'] !!}</svg>
|
||||
</div>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">{{ $card['value'] }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if ($branches->isNotEmpty())
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Branches</h2>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Branch</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queues</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
@foreach ($branches as $branch)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900 dark:text-white">{{ $branch->name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{{ $branch->service_queues_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $branch->is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600' }}">
|
||||
{{ $branch->is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-app-layout title="Register device">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<form method="POST" action="{{ route('qms.devices.store') }}" class="space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Type</label>
|
||||
<select name="type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($deviceTypes as $k => $v)<option value="{{ $k }}">{{ $v }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">—</option>
|
||||
@foreach ($branches as $b)<option value="{{ $b->id }}">{{ $b->name }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,22 @@
|
||||
<x-app-layout title="Devices">
|
||||
<div class="mb-6 flex justify-between">
|
||||
<h1 class="text-2xl font-semibold">Devices</h1>
|
||||
<a href="{{ route('qms.devices.create') }}" class="btn-primary">Register device</a>
|
||||
</div>
|
||||
@if (session('success'))<p class="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>@endif
|
||||
<div class="space-y-3">
|
||||
@foreach ($devices as $device)
|
||||
<div class="flex items-center justify-between rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div>
|
||||
<p class="font-semibold">{{ $device->name }}</p>
|
||||
<p class="text-sm text-slate-500">{{ config('qms.device_types.'.$device->type) }} · {{ $device->status }}</p>
|
||||
@if ($device->type === 'kiosk' && $device->device_token)
|
||||
<p class="mt-1 text-xs text-slate-400">Kiosk URL: {{ route('qms.kiosk.device', $device->device_token) }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('qms.devices.regenerate-token', $device) }}">@csrf<button class="btn-secondary text-sm">Regenerate token</button></form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
{{ $devices->links() }}
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-950 text-white">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $screen->name }} · Queue Display</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen p-8" x-data="displayBoard('{{ $dataUrl }}')" x-init="init()">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold" x-text="payload?.screen?.name ?? '{{ $screen->name }}'"></h1>
|
||||
<p class="text-slate-400" x-text="payload?.updated_at ? 'Updated ' + new Date(payload.updated_at).toLocaleTimeString() : ''"></p>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-widest text-indigo-300">Now serving</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<template x-for="item in (payload?.now_serving ?? [])" :key="item.ticket_number + item.counter">
|
||||
<div class="rounded-2xl bg-indigo-600 p-8 text-center shadow-lg">
|
||||
<p class="text-5xl font-bold" x-text="item.ticket_number"></p>
|
||||
<p class="mt-2 text-xl" x-text="item.queue_name"></p>
|
||||
<p class="mt-4 text-2xl font-semibold" x-text="'→ ' + (item.counter ?? '')"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900 p-6 text-center">
|
||||
<p class="text-sm uppercase text-slate-400">Waiting</p>
|
||||
<p class="mt-2 text-5xl font-bold" x-text="payload?.waiting_count ?? '—'"></p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900 p-6 text-center">
|
||||
<p class="text-sm uppercase text-slate-400">Est. wait</p>
|
||||
<p class="mt-2 text-5xl font-bold" x-text="payload?.estimated_wait_minutes ? payload.estimated_wait_minutes + ' min' : '—'"></p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900 p-6">
|
||||
<p class="mb-3 text-sm uppercase text-slate-400">Queues</p>
|
||||
<template x-for="q in (payload?.queues ?? [])" :key="q.name">
|
||||
<p class="text-lg" x-text="q.name + (q.is_paused ? ' (paused)' : '')"></p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<x-app-layout title="New display">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<form method="POST" action="{{ route('qms.displays.store') }}" class="space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($branches as $b)<option value="{{ $b->id }}">{{ $b->name }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Layout</label>
|
||||
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($layouts as $k => $v)<option value="{{ $k }}">{{ $v }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Queues on screen</label>
|
||||
@foreach ($queues as $q)
|
||||
<label class="mt-2 flex gap-2 text-sm"><input type="checkbox" name="queue_ids[]" value="{{ $q->id }}"> {{ $q->name }}</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Create display</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-app-layout title="Edit display">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Edit display</h1>
|
||||
<form method="POST" action="{{ route('qms.displays.update', $display) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $display->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($branches as $b)<option value="{{ $b->id }}" @selected($display->branch_id==$b->id)>{{ $b->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Layout</label>
|
||||
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($layouts as $k=>$l)<option value="{{ $k }}" @selected($display->layout==$k)>{{ $l }}</option>@endforeach</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Queues</label>
|
||||
<select name="queue_ids[]" multiple class="mt-1 w-full rounded-lg border-slate-300 text-sm">@foreach($queues as $q)<option value="{{ $q->id }}" @selected(in_array($q->id, $display->service_queue_ids ?? []))>{{ $q->name }}</option>@endforeach</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($display->is_active)> Active</label>
|
||||
<button type="submit" class="btn-primary w-full">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-app-layout title="Displays">
|
||||
<div class="mb-6 flex justify-between">
|
||||
<h1 class="text-2xl font-semibold">Display screens</h1>
|
||||
<a href="{{ route('qms.displays.create') }}" class="btn-primary">New display</a>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@foreach ($screens as $screen)
|
||||
<div class="flex items-center justify-between rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div>
|
||||
<p class="font-semibold">{{ $screen->name }}</p>
|
||||
<p class="text-sm text-slate-500">{{ config('qms.display_layouts.'.$screen->layout) }}</p>
|
||||
</div>
|
||||
<a href="{{ route('qms.display.public', $screen->access_token) }}" target="_blank" class="btn-secondary text-sm">Open display</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
{{ $screens->links() }}
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,11 @@
|
||||
<x-app-layout :title="$display->name">
|
||||
<div class="mb-6 flex justify-between">
|
||||
<div>
|
||||
<a href="{{ route('qms.displays.index') }}" class="text-sm text-indigo-600">← Displays</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{{ $display->name }}</h1>
|
||||
</div>
|
||||
<a href="{{ route('qms.displays.edit', $display) }}" class="btn-secondary">Edit</a>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600">Layout: {{ config('qms.display_layouts.'.$display->layout) }}</p>
|
||||
<p class="mt-2 text-sm"><a href="{{ route('qms.display.public', $display->access_token) }}" class="text-indigo-600" target="_blank">Open public display</a></p>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,29 @@
|
||||
<x-app-layout title="Customer feedback">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Customer feedback</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Average rating: {{ $avgRating ?: '—' }} / 5</p>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Ticket</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Rating</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Comments</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">When</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@forelse ($feedback as $item)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm">{{ $item->ticket?->ticket_number }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $item->rating }}/5</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600">{{ Str::limit($item->comments, 80) }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $item->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-6 py-12 text-center text-sm text-slate-500">No feedback yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $feedback->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@include('partials.favicon')
|
||||
<title>Feedback — Ladill Queue</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<div class="mx-auto max-w-md px-4 py-12">
|
||||
<h1 class="text-2xl font-semibold">How was your visit?</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Ticket {{ $ticket->ticket_number }}</p>
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@elseif ($existing)
|
||||
<p class="mt-6 text-sm text-slate-600">You already submitted feedback. Thank you!</p>
|
||||
@else
|
||||
<form method="POST" action="{{ route('qms.feedback.store', $ticket->qr_token) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Overall rating</label>
|
||||
<select name="rating" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@for ($i = 5; $i >= 1; $i--)
|
||||
<option value="{{ $i }}">{{ $i }} star{{ $i > 1 ? 's' : '' }}</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Comments</label>
|
||||
<textarea name="comments" rows="4" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Submit feedback</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-900">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Kiosk · {{ $device->name }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center p-6 text-white" x-data="kioskFlow({ issueUrl: '{{ route('qms.kiosk.device.issue', $device->device_token) }}', csrf: '{{ csrf_token() }}' })">
|
||||
<div class="w-full max-w-lg">
|
||||
<h1 class="mb-8 text-center text-3xl font-bold">{{ $device->name }}</h1>
|
||||
|
||||
<template x-if="step === 'select'">
|
||||
<div class="space-y-3">
|
||||
<p class="mb-4 text-center text-slate-300">Select a service</p>
|
||||
@foreach ($queues as $queue)
|
||||
<button type="button" @click="selectQueue({{ $queue->id }})"
|
||||
class="w-full rounded-2xl border border-slate-600 bg-slate-800 px-6 py-5 text-left text-xl font-semibold hover:border-indigo-400"
|
||||
style="border-left: 6px solid {{ $queue->color }}">
|
||||
{{ $queue->name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="step === 'details'">
|
||||
<div class="rounded-2xl bg-slate-800 p-6 space-y-4">
|
||||
<input type="text" x-model="customerName" placeholder="Your name (optional)" class="w-full rounded-lg border-slate-600 bg-slate-900 px-4 py-3 text-white">
|
||||
<input type="tel" x-model="customerPhone" placeholder="Phone (optional)" class="w-full rounded-lg border-slate-600 bg-slate-900 px-4 py-3 text-white">
|
||||
<p x-show="error" class="text-red-400 text-sm" x-text="error"></p>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" @click="reset()" class="flex-1 rounded-lg border border-slate-600 py-3">Back</button>
|
||||
<button type="button" @click="issue()" :disabled="loading" class="flex-1 rounded-lg bg-indigo-600 py-3 font-semibold">Get ticket</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="step === 'ticket' && ticket">
|
||||
<div class="rounded-2xl bg-indigo-600 p-8 text-center">
|
||||
<p class="text-sm uppercase tracking-widest opacity-80">Your number</p>
|
||||
<p class="mt-2 text-6xl font-bold" x-text="ticket.ticket_number"></p>
|
||||
<p class="mt-4 text-xl" x-text="ticket.queue?.name"></p>
|
||||
<p class="mt-6 text-lg" x-text="ticket.estimated_wait_seconds ? 'Est. wait: ' + Math.ceil(ticket.estimated_wait_seconds / 60) + ' mins' : ''"></p>
|
||||
<button type="button" @click="reset()" class="mt-8 rounded-lg bg-white/20 px-6 py-3">Done</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Join queue · Ladill Queue</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 p-6">
|
||||
<div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Join a queue</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Enter your queue code and phone number to get a ticket remotely.</p>
|
||||
@if (session('success'))
|
||||
<p class="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('qms.mobile.join.store') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Queue code (UUID)</label>
|
||||
<input name="queue_uuid" required class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Paste queue UUID from your organization">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Your name</label>
|
||||
<input name="customer_name" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Phone</label>
|
||||
<input name="customer_phone" type="tel" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Join queue</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ticket {{ $ticket->ticket_number }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 p-6" x-data="mobileTracker('{{ $pollUrl }}')" x-init="init()">
|
||||
<div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
|
||||
@if (session('success'))
|
||||
<p class="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
<p class="text-sm uppercase tracking-wide text-slate-500">Your ticket</p>
|
||||
<p class="text-5xl font-bold text-indigo-600" x-text="data?.ticket_number ?? '{{ $ticket->ticket_number }}'"></p>
|
||||
<p class="mt-2 text-lg text-slate-700" x-text="data?.queue?.name ?? '{{ $ticket->serviceQueue?->name }}'"></p>
|
||||
<p class="mt-4 text-sm text-slate-600">Status: <span class="font-semibold capitalize" x-text="data?.status ?? '{{ $ticket->status }}'"></span></p>
|
||||
<p class="mt-2 text-sm text-slate-600" x-show="(data?.status ?? '{{ $ticket->status }}') === 'waiting'">
|
||||
<span x-text="ahead + ' customer(s) ahead'"></span>
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-slate-600" x-show="data?.counter">
|
||||
Proceed to <span class="font-semibold" x-text="data?.counter?.name"></span>
|
||||
</p>
|
||||
|
||||
@if (in_array($ticket->status, ['waiting', 'on_hold']))
|
||||
<form method="POST" action="{{ route('qms.mobile.delay', $ticket->qr_token) }}" class="mt-6 flex gap-2">
|
||||
@csrf
|
||||
<input type="number" name="minutes" value="15" min="5" max="120" class="w-20 rounded-lg border-slate-300 text-sm">
|
||||
<button type="submit" class="btn-secondary flex-1">Delay arrival</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('qms.mobile.cancel', $ticket->qr_token) }}" class="mt-2" onsubmit="return confirm('Cancel this ticket?')">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-sm text-red-600">Cancel ticket</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<x-app-layout title="Set up Queue">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Welcome to Ladill Queue</h1>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Configure your organization to start managing customer flow.</p>
|
||||
|
||||
<form method="POST" action="{{ route('qms.onboarding.store') }}" enctype="multipart/form-data" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Organization name</label>
|
||||
<input type="text" name="organization_name" value="{{ old('organization_name', $user->name."'s Organization") }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Industry</label>
|
||||
<select name="industry" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
@foreach ($industries as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('industry', 'custom') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Appointment mode</label>
|
||||
<select name="appointment_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
@foreach ($appointmentModes as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('appointment_mode', 'hybrid') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Primary branch</label>
|
||||
<input type="text" name="branch_name" value="{{ old('branch_name', 'Main Branch') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Branch address</label>
|
||||
<input type="text" name="branch_address" value="{{ old('branch_address') }}"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Branch phone</label>
|
||||
<input type="tel" name="branch_phone" value="{{ old('branch_phone') }}"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Logo (optional)</label>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="mt-2 block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-sky-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
@foreach ($timezones as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', config('app.timezone')) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary btn-primary-lg w-full">
|
||||
Complete setup
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,44 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Queue name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $queue->name ?? '') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Description</label>
|
||||
<textarea name="description" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('description', $queue->description ?? '') }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Branch</label>
|
||||
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id', $queue->branch_id ?? '') == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Ticket prefix</label>
|
||||
<input type="text" name="prefix" value="{{ old('prefix', $queue->prefix ?? 'A') }}" required maxlength="10" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Color</label>
|
||||
<input type="color" name="color" value="{{ old('color', $queue->color ?? '#4f46e5') }}" class="mt-1 h-10 w-full rounded-lg border-slate-300">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Queue strategy</label>
|
||||
<select name="strategy" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($strategies as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('strategy', $queue->strategy ?? 'fifo') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Avg service time (seconds)</label>
|
||||
<input type="number" name="avg_service_seconds" value="{{ old('avg_service_seconds', $queue->avg_service_seconds ?? 300) }}" min="60" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Max capacity</label>
|
||||
<input type="number" name="max_capacity" value="{{ old('max_capacity', $queue->max_capacity ?? '') }}" min="1" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<x-app-layout title="New queue">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Create service queue</h1>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Define how customers join and flow through this service point.</p>
|
||||
|
||||
<form method="POST" action="{{ route('qms.queues.store') }}" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
@include('qms.queues._form', ['queue' => null])
|
||||
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ route('qms.queues.index') }}" class="btn-secondary flex-1 justify-center">Cancel</a>
|
||||
<button type="submit" class="btn-primary flex-1">Create queue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,11 @@
|
||||
<x-app-layout title="Edit queue">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-2xl font-semibold">Edit queue</h1>
|
||||
<form method="POST" action="{{ route('qms.queues.update', $queue) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
@include('qms.queues._form', ['queue' => $queue])
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($queue->is_active)> Active</label>
|
||||
<button type="submit" class="btn-primary w-full">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,73 @@
|
||||
<x-app-layout title="Queues">
|
||||
<div class="mb-6 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Service queues</h1>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Configure queues for reception, counters, and departments.</p>
|
||||
</div>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'queues.manage'))
|
||||
<a href="{{ route('qms.queues.create') }}" class="btn-primary">New queue</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
@if ($queues->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-slate-500">
|
||||
No queues yet. Create your first service queue to start issuing tickets.
|
||||
</div>
|
||||
@else
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queue</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Branch</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Prefix</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Strategy</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
@foreach ($queues as $queue)
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {{ $queue->color }}"></span>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<a href="{{ route('qms.queues.show', $queue) }}" class="hover:text-indigo-600">{{ $queue->name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{{ $queue->branch?->name }}</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-slate-600 dark:text-slate-400">{{ $queue->prefix }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{{ config('qms.queue_strategies.'.$queue->strategy, $queue->strategy) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($queue->is_paused)
|
||||
<span class="inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">Paused</span>
|
||||
<form method="POST" action="{{ route('qms.queues.resume', $queue) }}" class="inline">@csrf<button class="text-xs text-indigo-600">Resume</button></form>
|
||||
@elseif ($queue->is_active)
|
||||
<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">Active</span>
|
||||
<form method="POST" action="{{ route('qms.queues.pause', $queue) }}" class="inline">@csrf<button class="text-xs text-amber-600">Pause</button></form>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">Inactive</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'rules.view'))
|
||||
<a href="{{ route('qms.rules.index', $queue) }}" class="text-xs text-indigo-600">Rules</a>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="border-t border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
{{ $queues->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,19 @@
|
||||
<x-app-layout :title="$queue->name">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ route('qms.queues.index') }}" class="text-sm text-indigo-600">← Queues</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{{ $queue->name }}</h1>
|
||||
<p class="text-sm text-slate-600">{{ $queue->branch?->name }} · {{ config('qms.queue_strategies.'.$queue->strategy) }}</p>
|
||||
</div>
|
||||
<a href="{{ route('qms.queues.edit', $queue) }}" class="btn-secondary">Edit</a>
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Waiting</div><div class="mt-1 text-2xl font-semibold">{{ $waiting }}</div></div>
|
||||
<div class="rounded-2xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Prefix</div><div class="mt-1 text-2xl font-mono">{{ $queue->prefix }}</div></div>
|
||||
<div class="rounded-2xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Status</div><div class="mt-1 text-lg">{{ $queue->is_paused ? 'Paused' : ($queue->is_active ? 'Active' : 'Inactive') }}</div></div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a href="{{ route('qms.rules.index', $queue) }}" class="text-sm text-indigo-600">Queue rules</a>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,11 @@
|
||||
<x-app-layout title="Reports">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Reports</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($reports as $type => $label)
|
||||
<a href="{{ route('qms.reports.show', $type) }}" class="rounded-2xl border bg-white p-6 transition hover:border-indigo-300 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="font-semibold">{{ $label }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">View metrics and export CSV</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<x-app-layout :title="$label">
|
||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<a href="{{ route('qms.reports.index') }}" class="text-sm text-indigo-600">← Reports</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{{ $label }}</h1>
|
||||
</div>
|
||||
@if ($canExport)
|
||||
<a href="{{ route('qms.reports.export', array_merge(['type' => $type], request()->only(['from', 'to', 'branch_id']))) }}" class="btn-primary">Export CSV</a>
|
||||
@endif
|
||||
</div>
|
||||
<form method="GET" class="mb-6 flex flex-wrap gap-3 rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<input type="date" name="from" value="{{ $from }}" class="rounded-lg border-slate-300 text-sm">
|
||||
<input type="date" name="to" value="{{ $to }}" class="rounded-lg border-slate-300 text-sm">
|
||||
<select name="branch_id" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-slate-100 px-4 py-2 text-sm">Apply</button>
|
||||
</form>
|
||||
<div class="rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@if ($type === 'wait_times' && isset($data['hourly']))
|
||||
<table class="w-full text-sm"><thead><tr><th class="text-left py-2">Hour</th><th class="text-right">Avg wait (sec)</th></tr></thead>
|
||||
<tbody>@foreach ($data['hourly'] as $row)<tr class="border-t"><td class="py-2">{{ $row['hour'] }}:00</td><td class="text-right">{{ $row['avg_wait_seconds'] }}</td></tr>@endforeach</tbody>
|
||||
</table>
|
||||
@elseif ($type === 'counters' && isset($data['counters']))
|
||||
<table class="w-full text-sm"><thead><tr><th class="text-left py-2">Counter</th><th class="text-right">Served</th></tr></thead>
|
||||
<tbody>@foreach ($data['counters'] as $row)<tr class="border-t"><td class="py-2">{{ $row['name'] }}</td><td class="text-right">{{ $row['served'] }}</td></tr>@endforeach</tbody>
|
||||
</table>
|
||||
@else
|
||||
<dl class="space-y-3">
|
||||
@foreach ($data as $key => $value)
|
||||
@if (!is_array($value))
|
||||
<div class="flex justify-between border-b pb-2 text-sm"><dt class="text-slate-600">{{ str_replace('_', ' ', $key) }}</dt><dd class="font-medium">{{ $value }}</dd></div>
|
||||
@endif
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,68 @@
|
||||
<x-app-layout :title="'Rules — '.$queue->name">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('qms.queues.index') }}" class="text-sm text-indigo-600">← Queues</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">Queue rules</h1>
|
||||
<p class="text-sm text-slate-600">{{ $queue->name }}</p>
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="mb-6 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold">Add rule</h2>
|
||||
<form method="POST" action="{{ route('qms.rules.store', $queue) }}" class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Type</label>
|
||||
<select name="rule_type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($ruleTypes as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Overflow queue</label>
|
||||
<select name="overflow_queue_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">—</option>
|
||||
@foreach (\App\Models\ServiceQueue::owned($queue->owner_ref)->where('organization_id', $queue->organization_id)->where('id', '!=', $queue->id)->get() as $q)
|
||||
<option value="{{ $q->id }}">{{ $q->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Waiting threshold</label>
|
||||
<input name="waiting_threshold" type="number" min="1" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Boost priority to</label>
|
||||
<select name="priority" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('qms.ticket_priorities') as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2"><button type="submit" class="btn-primary">Add rule</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Conditions</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Actions</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@forelse ($rules as $rule)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm">{{ $ruleTypes[$rule->rule_type] ?? $rule->rule_type }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">{{ json_encode($rule->conditions) }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">{{ json_encode($rule->actions) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('qms.rules.destroy', [$queue, $rule]) }}">@csrf @method('DELETE')<button class="text-xs text-red-600">Remove</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-6 py-8 text-center text-sm text-slate-500">No rules yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,36 @@
|
||||
<x-app-layout title="Settings">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Organization settings</h1>
|
||||
<form method="POST" action="{{ route('qms.settings.update') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Organization name</label>
|
||||
<input name="name" value="{{ $organization->name }}" @disabled(! $canManage) required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Timezone</label>
|
||||
<input name="timezone" value="{{ $organization->timezone }}" @disabled(! $canManage) required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Appointment mode</label>
|
||||
<select name="appointment_mode" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($appointmentModes as $v => $l)
|
||||
<option value="{{ $v }}" @selected(($organization->settings['appointment_mode'] ?? 'hybrid') === $v)>{{ $l }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium">Industry</label>
|
||||
<select name="industry" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($industries as $v => $l)
|
||||
<option value="{{ $v }}" @selected(($organization->settings['industry'] ?? '') === $v)>{{ $l }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="notifications_enabled" value="1" @checked($organization->settings['notifications_enabled'] ?? true) @disabled(! $canManage)>
|
||||
Enable SMS/email queue notifications
|
||||
</label>
|
||||
@if ($canManage)
|
||||
<button type="submit" class="btn-primary w-full">Save settings</button>
|
||||
@endif
|
||||
</form>
|
||||
<p class="mt-4 text-sm text-slate-600">{{ $branchCount }} branch{{ $branchCount === 1 ? '' : 'es' }} configured.</p>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,33 @@
|
||||
<x-app-layout title="Issue ticket">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-2xl font-semibold">Issue ticket</h1>
|
||||
<form method="POST" action="{{ route('qms.tickets.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Queue</label>
|
||||
<select name="service_queue_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($queues as $queue)
|
||||
<option value="{{ $queue->id }}">{{ $queue->name }} ({{ $queue->branch?->name }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Customer name</label>
|
||||
<input name="customer_name" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Phone</label>
|
||||
<input name="customer_phone" type="tel" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Priority</label>
|
||||
<select name="priority" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('qms.ticket_priorities') as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Issue ticket</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,29 @@
|
||||
<x-app-layout title="Tickets">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Tickets</h1>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue'))
|
||||
<a href="{{ route('qms.tickets.create') }}" class="btn-primary">Issue ticket</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Ticket</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queue</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Issued</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
@foreach ($tickets as $ticket)
|
||||
<tr>
|
||||
<td class="px-4 py-3"><a href="{{ route('qms.tickets.show', $ticket) }}" class="font-mono font-semibold text-indigo-600">{{ $ticket->ticket_number }}</a></td>
|
||||
<td class="px-4 py-3 text-sm">{{ $ticket->serviceQueue?->name }}</td>
|
||||
<td class="px-4 py-3 text-sm capitalize">{{ str_replace('_', ' ', $ticket->status) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">{{ $ticket->issued_at?->format('M j, H:i') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="border-t px-4 py-3">{{ $tickets->links() }}</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>{{ $ticket->ticket_number }}</title>
|
||||
<style>body{font-family:system-ui;text-align:center;padding:2rem} .num{font-size:3rem;font-weight:bold}</style></head>
|
||||
<body onload="window.print()">
|
||||
<p>{{ $ticket->serviceQueue?->name }}</p>
|
||||
<p class="num">{{ $ticket->ticket_number }}</p>
|
||||
<p>{{ $ticket->issued_at?->format('M j, Y H:i') }}</p>
|
||||
@if ($ticket->estimated_wait_seconds)
|
||||
<p>Est. wait: {{ ceil($ticket->estimated_wait_seconds / 60) }} minutes</p>
|
||||
@endif
|
||||
<p style="font-size:.8rem;margin-top:2rem">{{ $ticket->barcode }}</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<x-app-layout title="Ticket {{ $ticket->ticket_number }}">
|
||||
<div class="mx-auto max-w-lg">
|
||||
@if (session('success'))
|
||||
<p class="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
<div class="rounded-2xl border bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900">
|
||||
<p class="text-sm uppercase text-slate-500">Ticket</p>
|
||||
<p class="text-5xl font-bold text-indigo-600">{{ $ticket->ticket_number }}</p>
|
||||
<p class="mt-2 text-lg">{{ $ticket->serviceQueue?->name }}</p>
|
||||
<p class="mt-1 capitalize text-slate-600">{{ str_replace('_', ' ', $ticket->status) }}</p>
|
||||
@if ($ticket->estimated_wait_seconds)
|
||||
<p class="mt-4 text-slate-600">Est. wait: {{ ceil($ticket->estimated_wait_seconds / 60) }} mins</p>
|
||||
@endif
|
||||
<p class="mt-4 text-sm text-slate-500">Track: {{ route('qms.mobile.show', $ticket->qr_token) }}</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<a href="{{ route('qms.tickets.print', $ticket) }}" target="_blank" class="btn-secondary">Print</a>
|
||||
<a href="{{ route('qms.tickets.index') }}" class="btn-primary">All tickets</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,50 @@
|
||||
<x-app-layout title="New workflow">
|
||||
<div class="mx-auto max-w-2xl" x-data="{
|
||||
steps: [{ name: '', service_queue_id: '' }],
|
||||
templates: @js($workflowTemplates),
|
||||
applyTemplate(key) {
|
||||
if (!this.templates[key]) return;
|
||||
this.steps = this.templates[key].map(s => ({ name: s.name, service_queue_id: '' }));
|
||||
}
|
||||
}">
|
||||
<h1 class="text-2xl font-semibold">Create workflow</h1>
|
||||
<form method="POST" action="{{ route('qms.workflows.store') }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Industry template</label>
|
||||
<select @change="applyTemplate($event.target.value)" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Custom</option>
|
||||
@foreach ($templates as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name</label>
|
||||
<input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Description</label>
|
||||
<textarea name="description" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></textarea>
|
||||
</div>
|
||||
<div class="border-t pt-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">Steps</h2>
|
||||
<button type="button" @click="steps.push({ name: '', service_queue_id: '' })" class="text-xs text-indigo-600">Add step</button>
|
||||
</div>
|
||||
<template x-for="(step, index) in steps" :key="index">
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 rounded-lg border p-3">
|
||||
<input :name="'steps['+index+'][name]'" x-model="step.name" placeholder="Step name" required class="rounded-lg border-slate-300 text-sm">
|
||||
<select :name="'steps['+index+'][service_queue_id]'" x-model="step.service_queue_id" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select queue</option>
|
||||
@foreach ($queues as $queue)
|
||||
<option value="{{ $queue->id }}">{{ $queue->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Create workflow</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,22 @@
|
||||
<x-app-layout :title="'Edit '.$workflow->name">
|
||||
<div class="mx-auto max-w-2xl" x-data="{ steps: @js($workflow->steps->map(fn($s) => ['name' => $s->name, 'service_queue_id' => $s->service_queue_id])->values()) }">
|
||||
<h1 class="text-2xl font-semibold">Edit workflow</h1>
|
||||
<form method="POST" action="{{ route('qms.workflows.update', $workflow) }}" class="mt-6 space-y-4 rounded-2xl border bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $workflow->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Description</label><textarea name="description" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ $workflow->description }}</textarea></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($workflow->is_active)> Active</label>
|
||||
<template x-for="(step, index) in steps" :key="index">
|
||||
<div class="grid grid-cols-2 gap-3 rounded-lg border p-3">
|
||||
<input :name="'steps['+index+'][name]'" x-model="step.name" required class="rounded-lg border-slate-300 text-sm">
|
||||
<select :name="'steps['+index+'][service_queue_id]'" x-model="step.service_queue_id" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select queue</option>
|
||||
@foreach ($queues as $queue)<option value="{{ $queue->id }}">{{ $queue->name }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="steps.push({name:'',service_queue_id:''})" class="text-sm text-indigo-600">Add step</button>
|
||||
<button type="submit" class="btn-primary w-full">Save workflow</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,29 @@
|
||||
<x-app-layout title="Workflows">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Workflows</h1>
|
||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'workflows.manage'))
|
||||
<a href="{{ route('qms.workflows.create') }}" class="btn-primary">New workflow</a>
|
||||
@endif
|
||||
</div>
|
||||
@include('partials.flash')
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Steps</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@forelse ($workflows as $workflow)
|
||||
<tr>
|
||||
<td class="px-4 py-3"><a href="{{ route('qms.workflows.show', $workflow) }}" class="text-sm font-medium text-indigo-600">{{ $workflow->name }}</a></td>
|
||||
<td class="px-4 py-3 text-sm">{{ $workflow->steps_count }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $workflow->is_active ? 'Active' : 'Inactive' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="px-6 py-12 text-center text-sm text-slate-500">No workflows configured.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,21 @@
|
||||
<x-app-layout :title="$workflow->name">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('qms.workflows.index') }}" class="text-sm text-indigo-600">← Workflows</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{{ $workflow->name }}</h1>
|
||||
@if ($workflow->description)<p class="mt-1 text-sm text-slate-600">{{ $workflow->description }}</p>@endif
|
||||
</div>
|
||||
<div class="rounded-2xl border bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold uppercase text-slate-500">Steps</h2>
|
||||
<ol class="mt-4 space-y-3">
|
||||
@foreach ($workflow->steps as $step)
|
||||
<li class="flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-50 text-sm font-semibold text-indigo-700">{{ $step->sort_order }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ $step->name }}</div>
|
||||
<div class="text-sm text-slate-500">{{ $step->serviceQueue?->name ?? 'No queue linked' }}</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
Reference in New Issue
Block a user