Initial Ladill Frontdesk release with deploy pipeline.
Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
|
||||
// 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();
|
||||
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,52 @@
|
||||
@php
|
||||
$signedOut = (array) config('signed_out');
|
||||
$logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillcrm-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,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign-in problem · Ladill CRM</title>
|
||||
@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('images/logo/ladillcrm-logo.svg') }}" alt="Ladill CRM" 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,34 @@
|
||||
@props(['title' => 'Ladill Frontdesk', '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 Frontdesk</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 dark:bg-slate-950 text-slate-900 dark:text-slate-100"
|
||||
x-data="{ sidebarOpen: false, dark: localStorage.getItem('fd-theme') === 'dark' }"
|
||||
x-init="$watch('dark', v => { localStorage.setItem('fd-theme', v ? 'dark' : 'light'); document.documentElement.classList.toggle('dark', v) }); document.documentElement.classList.toggle('dark', dark)">
|
||||
<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>
|
||||
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
|
||||
</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,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 CRM</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<x-app-layout title="Add branch">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add branch</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.branches.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">Name</label><input type="text" 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 type="text" 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 type="text" 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 type="tel" name="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Save</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-xl font-semibold text-slate-900">Edit branch</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.branches.update', $branch) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input type="text" name="name" value="{{ old('name', $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 type="text" name="code" value="{{ old('code', $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 type="text" name="address" value="{{ old('address', $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 type="tel" name="phone" value="{{ old('phone', $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(old('is_active', $branch->is_active))> Active</label>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,25 @@
|
||||
<x-app-layout title="Branches">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Branches</h1>
|
||||
@if (app(\App\Services\Frontdesk\FrontdeskPermissions::class)->can(auth()->user() ? app(\App\Services\Frontdesk\OrganizationResolver::class)->memberFor(auth()->user(), $organization) : null, 'admin.branches.manage'))
|
||||
<a href="{{ route('frontdesk.branches.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white">Add branch</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
@forelse ($branches as $branch)
|
||||
<div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{{ $branch->name }}</p>
|
||||
<p class="text-sm text-slate-500">{{ $branch->address ?? 'No address' }} · {{ $branch->buildings_count }} building(s)</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('frontdesk.buildings.index', $branch) }}" class="text-sm text-teal-600 hover:text-teal-700">Buildings</a>
|
||||
<a href="{{ route('frontdesk.branches.edit', $branch) }}" class="text-sm text-slate-600 hover:text-slate-800">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500">No branches yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<x-app-layout :title="$branch->name.' buildings'">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ route('frontdesk.branches.index') }}" class="text-sm text-teal-600">← Branches</a>
|
||||
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $branch->name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.buildings.store', $branch) }}" class="mt-6 flex gap-2 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
@csrf
|
||||
<input type="text" name="name" placeholder="Building name" required class="flex-1 rounded-lg border-slate-300 text-sm">
|
||||
<input type="text" name="floor_count" placeholder="Floors" class="w-24 rounded-lg border-slate-300 text-sm">
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white">Add</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@forelse ($buildings as $building)
|
||||
<div class="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div>
|
||||
<p class="font-medium">{{ $building->name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $building->reception_desks_count }} desk(s)</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ route('frontdesk.desks.index', $building) }}" class="text-sm text-teal-600">Desks</a>
|
||||
<form method="POST" action="{{ route('frontdesk.buildings.destroy', [$branch, $building]) }}" onsubmit="return confirm('Remove this building?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500">No buildings yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<x-app-layout :title="$building->name.' desks'">
|
||||
<a href="{{ route('frontdesk.buildings.index', $building->branch) }}" class="text-sm text-teal-600">← Buildings</a>
|
||||
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $building->name }}</h1>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.desks.store', $building) }}" class="mt-6 flex gap-2 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
@csrf
|
||||
<input type="text" name="name" placeholder="Desk name" required class="flex-1 rounded-lg border-slate-300 text-sm">
|
||||
<input type="text" name="location" placeholder="Location" class="flex-1 rounded-lg border-slate-300 text-sm">
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white">Add</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@forelse ($desks as $desk)
|
||||
<div class="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div>
|
||||
<p class="font-medium">{{ $desk->name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $desk->location ?? '—' }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('frontdesk.desks.destroy', [$building, $desk]) }}" onsubmit="return confirm('Remove this desk?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500">No reception desks yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<x-app-layout title="Add team member">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add team member</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter the Ladill user public ID (OIDC sub) of the person to invite.</p>
|
||||
<form method="POST" action="{{ route('frontdesk.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div><label class="block text-sm font-medium">User public ID</label><input type="text" 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" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($roles as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Branch (optional)</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Save member</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-app-layout title="Team members">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Team members</h1>
|
||||
<a href="{{ route('frontdesk.members.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white">Add member</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">User ID</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach ($members as $member)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $member->user_ref }}</td>
|
||||
<td class="px-4 py-3">{{ $roles[$member->role] ?? $member->role }}</td>
|
||||
<td class="px-4 py-3">{{ $member->branch?->name ?? 'All branches' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($member->user_ref !== auth()->user()->public_id)
|
||||
<form method="POST" action="{{ route('frontdesk.members.destroy', $member) }}" class="inline" onsubmit="return confirm('Remove this member?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-red-600">Remove</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,57 @@
|
||||
<x-app-layout title="Audit log">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Audit log</h1>
|
||||
<p class="text-sm text-slate-500">Security and compliance activity</p>
|
||||
</div>
|
||||
@if ($canExport)
|
||||
<a href="{{ route('frontdesk.audit.export', request()->query()) }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export CSV</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mt-4 grid gap-2 sm:grid-cols-4">
|
||||
<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>
|
||||
<input type="date" name="from" value="{{ request('from') }}" class="rounded-lg border-slate-300 text-sm">
|
||||
<input type="date" name="to" value="{{ request('to') }}" class="rounded-lg border-slate-300 text-sm">
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-4 py-2 text-sm sm:col-span-4 sm:w-fit">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Time</th>
|
||||
<th class="px-4 py-3">Action</th>
|
||||
<th class="px-4 py-3">Actor</th>
|
||||
<th class="px-4 py-3">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($logs as $log)
|
||||
<tr class="@if(str_starts_with($log->action, 'watchlist.')) bg-amber-50/40 @endif">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-slate-500">{{ $log->created_at?->format('M j, g:i A') }}</td>
|
||||
<td class="px-4 py-3 font-medium">{{ $actions[$log->action] ?? $log->action }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $log->actor_ref ?? 'System' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
@if ($log->metadata)
|
||||
{{ collect($log->metadata)->except('_awaiting_approval')->map(fn ($v, $k) => "$k: $v")->join(' · ') }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">No audit entries found.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $logs->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Visitor Badge · {{ $visit->badge_code }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; }
|
||||
.badge { width: 3.5in; border: 2px solid #0d9488; border-radius: 12px; padding: 1rem; }
|
||||
.org { font-size: 0.75rem; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.name { font-size: 1.5rem; font-weight: 700; margin: 0.5rem 0; }
|
||||
.meta { font-size: 0.875rem; color: #444; line-height: 1.5; }
|
||||
.code { font-family: monospace; font-size: 1.25rem; font-weight: 700; color: #0d9488; margin-top: 1rem; }
|
||||
.expiry { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
|
||||
@media print { body { padding: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body onload="window.print()">
|
||||
<div class="badge">
|
||||
<div class="org">{{ $visit->organization->name ?? 'Visitor' }}</div>
|
||||
<div class="name">{{ $visit->visitor->full_name }}</div>
|
||||
<div class="meta">
|
||||
@if ($visit->visitor->company){{ $visit->visitor->company }}<br>@endif
|
||||
Host: {{ $visit->host?->name ?? '—' }}<br>
|
||||
Type: {{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}<br>
|
||||
Check-in: {{ $visit->checked_in_at?->format('g:i A') }}
|
||||
</div>
|
||||
<div class="code">{{ $visit->badge_code }}</div>
|
||||
<div class="expiry">Expires {{ $visit->badge_expires_at?->format('g:i A') ?? 'end of day' }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Visitor Badge · {{ $visit->badge_code }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background: #f8fafc; }
|
||||
.badge { width: 3.5in; border: 2px solid {{ $template['primary_color'] ?? '#0d9488' }}; border-radius: 12px; padding: 1rem; background: #fff; }
|
||||
.org { font-size: 0.75rem; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.name { font-size: 1.5rem; font-weight: 700; margin: 0.5rem 0; color: #0f172a; }
|
||||
.meta { font-size: 0.875rem; color: #444; line-height: 1.5; }
|
||||
.code { font-family: monospace; font-size: 1.25rem; font-weight: 700; color: {{ $template['primary_color'] ?? '#0d9488' }}; margin-top: 1rem; }
|
||||
.expiry { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
|
||||
.photo { width: 72px; height: 72px; border-radius: 8px; object-fit: cover; float: right; margin-left: 0.75rem; }
|
||||
.qr { margin-top: 0.75rem; }
|
||||
.footer { font-size: 0.7rem; color: #94a3b8; margin-top: 0.75rem; }
|
||||
.preview-bar { margin-bottom: 1rem; padding: 0.75rem 1rem; background: #ecfeff; border: 1px solid #99f6e4; border-radius: 8px; font-size: 0.875rem; }
|
||||
@media print { body { padding: 0; background: #fff; } .preview-bar { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body @if($autoPrint ?? false) onload="window.print()" @endif>
|
||||
@if ($preview ?? false)
|
||||
<div class="preview-bar">Badge preview — <a href="{{ route('frontdesk.visits.badge', $visit) }}" target="_blank">Print badge</a></div>
|
||||
@endif
|
||||
<div class="badge">
|
||||
@if (($template['show_photo'] ?? true) && $photoUrl)
|
||||
<img src="{{ $photoUrl }}" alt="Visitor photo" class="photo">
|
||||
@endif
|
||||
<div class="org">{{ $visit->organization->name ?? 'Visitor' }}</div>
|
||||
<div class="name">{{ $visit->visitor->full_name }}</div>
|
||||
<div class="meta">
|
||||
@if (($template['show_company'] ?? true) && $visit->visitor->company){{ $visit->visitor->company }}<br>@endif
|
||||
@if ($template['show_host'] ?? true)Host: {{ $visit->host?->name ?? '—' }}<br>@endif
|
||||
@if ($template['show_type'] ?? true)Type: {{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}<br>@endif
|
||||
Check-in: {{ $visit->checked_in_at?->format('g:i A') }}
|
||||
</div>
|
||||
<div class="code">{{ $visit->badge_code }}</div>
|
||||
<div class="expiry">Expires {{ $visit->badge_expires_at?->format('g:i A') ?? 'end of day' }}</div>
|
||||
@if (($template['show_qr'] ?? true) && $qrSvg)
|
||||
<div class="qr">{!! $qrSvg !!}</div>
|
||||
@endif
|
||||
@if (! empty($template['footer_text']))
|
||||
<div class="footer">{{ $template['footer_text'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<x-app-layout title="Badge template">
|
||||
<h1 class="text-xl font-semibold text-slate-900 dark:text-slate-100">Badge template</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Configure fields shown on printed visitor badges.</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.settings.badge.update') }}" class="mt-6 max-w-2xl space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Layout</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
@foreach ([
|
||||
'show_photo' => 'Visitor photo',
|
||||
'show_qr' => 'QR code',
|
||||
'show_host' => 'Host name',
|
||||
'show_company' => 'Company',
|
||||
'show_type' => 'Visitor type',
|
||||
] as $key => $label)
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" name="{{ $key }}" value="1" @checked(old($key, $template[$key] ?? true))>
|
||||
{{ $label }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Accent color</label>
|
||||
<input type="color" name="primary_color" value="{{ old('primary_color', $template['primary_color'] ?? '#0d9488') }}" class="mt-1 h-10 w-20 rounded border-slate-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Footer text</label>
|
||||
<input type="text" name="footer_text" value="{{ old('footer_text', $template['footer_text'] ?? '') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">Save template</button>
|
||||
@endif
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@if ($sampleVisit)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Live preview</h2>
|
||||
<iframe src="{{ route('frontdesk.visits.badge.preview', $sampleVisit) }}" class="mt-3 h-96 w-full max-w-md rounded-2xl border border-slate-200 dark:border-slate-700"></iframe>
|
||||
</div>
|
||||
@endif
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,46 @@
|
||||
<x-app-layout title="Recovery">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Archived records</h1>
|
||||
<p class="text-sm text-slate-500">Restore soft-deleted visitors and visits</p>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Archived visitors</h2>
|
||||
</div>
|
||||
@forelse ($deletedVisitors as $visitor)
|
||||
<div class="flex items-center justify-between border-b border-slate-50 px-5 py-3 last:border-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-800">{{ $visitor->full_name }}</p>
|
||||
<p class="text-xs text-slate-400">Deleted {{ $visitor->deleted_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('frontdesk.compliance.visitors.restore', $visitor->id) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-teal-600 hover:text-teal-800">Restore</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No archived visitors.</p>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Archived visits</h2>
|
||||
</div>
|
||||
@forelse ($deletedVisits as $visit)
|
||||
<div class="flex items-center justify-between border-b border-slate-50 px-5 py-3 last:border-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-800">{{ $visit->visitor?->full_name ?? 'Unknown visitor' }}</p>
|
||||
<p class="text-xs text-slate-400">Deleted {{ $visit->deleted_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('frontdesk.compliance.visits.restore', $visit->id) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-teal-600 hover:text-teal-800">Restore</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No archived visits.</p>
|
||||
@endforelse
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,92 @@
|
||||
<x-app-layout title="Reception Dashboard">
|
||||
@php
|
||||
$cards = [
|
||||
['label' => 'Visitors Today', 'value' => number_format($stats['visitors_today']), 'href' => route('frontdesk.visits.index')],
|
||||
['label' => 'Currently Inside', 'value' => number_format($stats['currently_inside']), 'href' => route('frontdesk.visits.index', ['status' => 'checked_in'])],
|
||||
['label' => 'Expected Arrivals', 'value' => number_format($stats['expected_arrivals']), 'href' => route('frontdesk.visits.index', ['status' => 'expected'])],
|
||||
['label' => 'Waiting', 'value' => number_format($stats['waiting']), 'href' => route('frontdesk.visits.index', ['status' => 'waiting'])],
|
||||
['label' => 'Overdue', 'value' => number_format($stats['overdue']), 'href' => route('frontdesk.visits.index', ['status' => 'overdue'])],
|
||||
['label' => 'Pending Approval', 'value' => number_format($stats['pending_approvals']), 'href' => route('frontdesk.visits.index', ['status' => 'waiting'])],
|
||||
['label' => 'Checked Out', 'value' => number_format($stats['checked_out_today']), 'href' => route('frontdesk.visits.index', ['status' => 'checked_out'])],
|
||||
['label' => 'Deliveries', 'value' => number_format($stats['deliveries_today']), 'href' => route('frontdesk.visits.index')],
|
||||
['label' => 'Contractors', 'value' => number_format($stats['contractors_today']), 'href' => route('frontdesk.visits.index')],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">{{ $organization->name }}</h1>
|
||||
<p class="text-sm text-slate-500">Reception dashboard</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('frontdesk.visits.schedule') }}" class="inline-flex items-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Pre-register
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.visits.create') }}" class="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">
|
||||
Check in visitor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
@foreach ($cards as $card)
|
||||
<a href="{{ $card['href'] }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-teal-300 hover:shadow-sm">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">{{ $card['label'] }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $card['value'] }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Currently inside</h2>
|
||||
<a href="{{ route('frontdesk.security.index') }}" class="text-xs font-medium text-teal-600 hover:text-teal-700">Security view</a>
|
||||
</div>
|
||||
@forelse ($currentVisitors as $visit)
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}" class="flex items-center gap-3 border-b border-slate-50 px-5 py-3 last:border-0 hover:bg-slate-50">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-teal-50 text-sm font-semibold text-teal-700">{{ strtoupper(substr($visit->visitor->full_name, 0, 1)) }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ $visit->visitor->full_name }}</p>
|
||||
<p class="truncate text-xs text-slate-400">Host: {{ $visit->host?->name ?? '—' }} · {{ $visit->checked_in_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No visitors currently inside.</p>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Expected today</h2>
|
||||
<a href="{{ route('frontdesk.visits.calendar') }}" class="text-xs font-medium text-teal-600 hover:text-teal-700">Calendar</a>
|
||||
</div>
|
||||
@forelse ($expectedVisitors as $visit)
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}" class="flex items-center gap-3 border-b border-slate-50 px-5 py-3 last:border-0 hover:bg-slate-50 @if($visit->status === 'overdue') bg-amber-50/50 @endif">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ $visit->visitor->full_name }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ $visit->scheduled_at?->format('g:i A') ?? 'TBD' }} · {{ $visit->host?->name }} · {{ str_replace('_', ' ', $visit->status) }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No expected arrivals today.</p>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
@if ($pendingApprovals->isNotEmpty())
|
||||
<section class="rounded-2xl border border-amber-200 bg-white lg:col-span-2">
|
||||
<div class="border-b border-amber-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-amber-900">Pending approval</h2>
|
||||
</div>
|
||||
@foreach ($pendingApprovals as $visit)
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}" class="flex items-center gap-3 border-b border-slate-50 px-5 py-3 last:border-0 hover:bg-amber-50/50">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ $visit->visitor->full_name }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }} · {{ $visit->host?->name }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,40 @@
|
||||
<x-app-layout title="Add device">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Register device</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.devices.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name') }}" required placeholder="Lobby kiosk" 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 ($deviceTypes as $key => $label)
|
||||
<option value="{{ $key }}" @selected(old('type') === $key)>{{ $label }}</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="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Reception desk</label>
|
||||
<select name="reception_desk_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($desks as $desk)
|
||||
<option value="{{ $desk->id }}" @selected(old('reception_desk_id') == $desk->id)>{{ $desk->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">Kiosks and printers receive a device token for unattended access.</p>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Register device</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,73 @@
|
||||
<x-app-layout title="Edit device">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Edit device</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.devices.update', $device) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $device->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 ($deviceTypes as $key => $label)
|
||||
<option value="{{ $key }}" @selected(old('type', $device->type) === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (['online', 'offline', 'maintenance'] as $status)
|
||||
<option value="{{ $status }}" @selected(old('status', $device->status) === $status)>{{ ucfirst($status) }}</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="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id', $device->branch_id) == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Reception desk</label>
|
||||
<select name="reception_desk_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($desks as $desk)
|
||||
<option value="{{ $desk->id }}" @selected(old('reception_desk_id', $device->reception_desk_id) == $desk->id)>{{ $desk->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if ($device->device_token)
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium uppercase text-slate-500">Device token</p>
|
||||
<code class="mt-1 block break-all text-xs text-slate-700">{{ $device->device_token }}</code>
|
||||
@if ($device->type === 'kiosk')
|
||||
<a href="{{ route('frontdesk.kiosk.device', $device->device_token) }}" target="_blank" class="mt-2 inline-block text-sm text-teal-700 hover:underline">Open kiosk URL</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<a href="{{ route('frontdesk.devices.index') }}" class="flex-1 rounded-lg border border-slate-200 py-2 text-center text-sm">Back</a>
|
||||
<button type="submit" class="flex-1 rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($device->device_token)
|
||||
<form method="POST" action="{{ route('frontdesk.devices.regenerate-token', $device) }}" class="mt-3">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-amber-700 hover:underline" onclick="return confirm('Regenerate token? Existing kiosk sessions will stop working.')">Regenerate token</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.devices.destroy', $device) }}" class="mt-4" onsubmit="return confirm('Remove this device?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:underline">Remove device</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<x-app-layout title="Devices">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Devices</h1>
|
||||
<p class="text-sm text-slate-500">Kiosks, printers, and reception hardware</p>
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.devices.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Add device</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Desk / branch</th>
|
||||
<th class="px-4 py-3">Last online</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($devices as $device)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $device->name }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $deviceTypes[$device->type] ?? $device->type }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $device->isOnline() ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600' }}">
|
||||
{{ $device->isOnline() ? 'Online' : ucfirst($device->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ $device->receptionDesk?->name ?? '—' }}
|
||||
@if ($device->branch)
|
||||
<span class="text-slate-400">· {{ $device->branch->name }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ $device->last_online_at?->diffForHumans() ?? 'Never' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.devices.edit', $device) }}" class="text-teal-700 hover:underline">Edit</a>
|
||||
@endif
|
||||
@if ($device->type === 'kiosk' && $device->device_token)
|
||||
<a href="{{ route('frontdesk.kiosk.device', $device->device_token) }}" target="_blank" class="ml-3 text-teal-700 hover:underline">Open kiosk</a>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No devices registered yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ $devices->links() }}
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,89 @@
|
||||
<x-app-layout title="Host portal">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Host portal</h1>
|
||||
<p class="text-sm text-slate-500">{{ $host->name }} · {{ $organization->name }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('frontdesk.host.availability') }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-50">
|
||||
{{ $host->is_available ? 'Mark unavailable' : 'Mark available' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ route('frontdesk.host.schedule') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Pre-register visitor</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($pending->isNotEmpty())
|
||||
<section class="mt-6">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Awaiting your approval</h2>
|
||||
<div class="mt-3 space-y-2">
|
||||
@foreach ($pending as $visit)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{{ $visit->visitor->full_name }}</p>
|
||||
<p class="text-sm text-slate-600 capitalize">{{ str_replace('_', ' ', $visit->visitor_type) }} · {{ $visit->purpose ?: 'No purpose given' }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('frontdesk.host.approve', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">Approve</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Upcoming visits</h2>
|
||||
<div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Visitor</th>
|
||||
<th class="px-4 py-3">When</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($upcoming as $visit)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $visit->visitor->full_name }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $visit->scheduled_at?->format('M j, g:i A') ?? '—' }}</td>
|
||||
<td class="px-4 py-3 capitalize">{{ str_replace('_', ' ', $visit->status) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="px-4 py-8 text-center text-slate-500">No upcoming visits.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Recent history</h2>
|
||||
<div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Visitor</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($recent as $visit)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $visit->visitor->full_name }}</td>
|
||||
<td class="px-4 py-3 capitalize">{{ str_replace('_', ' ', $visit->status) }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $visit->updated_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="px-4 py-8 text-center text-slate-500">No recent visits.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-app-layout title="Pre-register visitor">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Pre-register visitor</h1>
|
||||
<p class="text-sm text-slate-500">Schedule a visit as {{ $host->name }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.host.schedule.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Visitor name</label>
|
||||
<input type="text" name="full_name" value="{{ old('full_name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Company</label>
|
||||
<input type="text" name="company" value="{{ old('company') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Visitor type</label>
|
||||
<select name="visitor_type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('frontdesk.visitor_types') as $key => $label)
|
||||
<option value="{{ $key }}" @selected(old('visitor_type', 'visitor') === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Email</label>
|
||||
<input type="email" name="email" value="{{ old('email') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Phone</label>
|
||||
<input type="tel" name="phone" value="{{ old('phone') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Purpose</label>
|
||||
<input type="text" name="purpose" value="{{ old('purpose') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Scheduled date & time</label>
|
||||
<input type="datetime-local" name="scheduled_at" value="{{ old('scheduled_at') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Notes</label>
|
||||
<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('notes') }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ route('frontdesk.host.index') }}" class="flex-1 rounded-lg border border-slate-200 py-2 text-center text-sm font-medium">Cancel</a>
|
||||
<button type="submit" class="flex-1 rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Schedule visit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-app-layout title="Add host">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add host</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.hosts.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Name</label>
|
||||
<input type="text" name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Department</label>
|
||||
<input type="text" name="department" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Office</label>
|
||||
<input type="text" name="office" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" 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 type="tel" name="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white">Save host</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,33 @@
|
||||
<x-app-layout title="Edit host">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Edit host</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.hosts.update', $host) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div><label class="block text-sm font-medium">Name</label><input type="text" name="name" value="{{ old('name', $host->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div><label class="block text-sm font-medium">Department</label><input type="text" name="department" value="{{ old('department', $host->department) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Office</label><input type="text" name="office" value="{{ old('office', $host->office) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div><label class="block text-sm font-medium">Email</label><input type="email" name="email" value="{{ old('email', $host->email) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
<div><label class="block text-sm font-medium">Phone</label><input type="tel" name="phone" value="{{ old('phone', $host->phone) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
||||
</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="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id', $host->branch_id) == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_available" value="1" @checked(old('is_available', $host->is_available))> Available for visits</label>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Linked user (platform ID)</label>
|
||||
<input type="text" name="user_ref" value="{{ old('user_ref', $host->user_ref) }}" placeholder="User public_id for host portal access" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">When set, this user can access the host self-service portal.</p>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 py-2 text-sm font-semibold text-white">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,36 @@
|
||||
<x-app-layout title="Host directory">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Host directory</h1>
|
||||
<a href="{{ route('frontdesk.hosts.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Add host</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Department</th>
|
||||
<th class="px-4 py-3">Office</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($hosts as $host)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $host->name }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $host->department ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $host->office ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $host->email ?? $host->phone ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('frontdesk.hosts.edit', $host) }}" class="text-sm text-teal-600">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">No hosts yet. Add your first host to enable visitor notifications.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $hosts->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,52 @@
|
||||
<x-app-layout title="Integrations">
|
||||
<h1 class="text-xl font-semibold text-slate-900 dark:text-slate-100">Integrations</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Connect Ladill apps and external systems.</p>
|
||||
|
||||
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Supported Ladill apps</h2>
|
||||
<ul class="mt-3 grid gap-2 sm:grid-cols-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
@foreach ($integrations as $key => $label)
|
||||
<li class="rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800">{{ $label }} <span class="text-slate-400">({{ $key }})</span></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-3 text-xs text-slate-500">Sibling apps authenticate with service API keys and can create visits via <code class="text-xs">POST /api/visits</code> with <code class="text-xs">external_ref</code> for idempotency.</p>
|
||||
</section>
|
||||
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('frontdesk.integrations.update') }}" class="mt-6 max-w-2xl space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Outbound webhooks</h2>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Webhook URL</label>
|
||||
<input type="url" name="webhook_url" value="{{ old('webhook_url', $webhook?->url) }}" placeholder="https://example.com/webhooks/frontdesk" 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">Signing secret</label>
|
||||
<input type="text" name="webhook_secret" value="{{ old('webhook_secret', $webhook?->secret) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">Events</p>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($webhookEvents as $event)
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="webhook_events[]" value="{{ $event }}"
|
||||
@checked(in_array($event, old('webhook_events', $webhook?->events ?? $webhookEvents)))>
|
||||
{{ $event }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="webhook_active" value="1" @checked(old('webhook_active', $webhook?->is_active ?? true))> Active</label>
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white">Save integrations</button>
|
||||
</section>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Calendar feed (iCal)</h2>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">Subscribe to scheduled visits in Outlook, Google Calendar, or Apple Calendar.</p>
|
||||
<code class="mt-3 block break-all rounded-lg bg-slate-50 p-3 text-xs dark:bg-slate-800">{{ $icalUrl }}</code>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>Visitor Check-in · {{ $organization->name }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-teal-600 to-cyan-700 text-white" x-data="kioskFlow()" x-init="startTimer()">
|
||||
<div class="mx-auto flex min-h-screen max-w-3xl flex-col p-6">
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold">{{ $organization->name }}</h1>
|
||||
<p class="mt-2 text-teal-100">Welcome — please check in</p>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<template x-if="step === 'welcome'">
|
||||
<div class="text-center">
|
||||
<button @click="step = 'type'; resetTimer()" class="mx-auto block w-full max-w-md rounded-3xl bg-white px-8 py-16 text-2xl font-bold text-teal-700 shadow-xl transition hover:scale-[1.02]">
|
||||
Tap to begin
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="step === 'type'">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@foreach ($visitorTypes as $key => $label)
|
||||
<button @click="selectType('{{ $key }}')"
|
||||
class="rounded-2xl bg-white/95 px-6 py-8 text-lg font-semibold text-slate-800 shadow-lg hover:bg-white">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="step === 'details'">
|
||||
<form @submit.prevent="submitCheckIn" class="space-y-4 rounded-3xl bg-white p-6 text-slate-800 shadow-xl">
|
||||
<p class="text-sm font-medium text-teal-700" x-text="typeConfigs[form.visitor_type]?.label"></p>
|
||||
|
||||
<input type="text" x-model="form.full_name" required placeholder="Full name" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="text" x-model="form.company" placeholder="Company" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="tel" x-model="form.phone" placeholder="Phone" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="email" x-model="form.email" placeholder="Email" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<select x-model="form.host_id" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<option value="">Who are you visiting?</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}">{{ $host->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" x-model="form.purpose" placeholder="Purpose of visit" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<p class="mb-2 text-sm font-medium text-slate-600">Visitor photo (optional)</p>
|
||||
<video x-ref="camera" x-show="cameraActive" autoplay playsinline class="mb-2 w-full max-h-48 rounded-lg bg-slate-900 object-cover"></video>
|
||||
<img x-show="form.photo_data" :src="form.photo_data" alt="Captured photo preview" class="mb-2 max-h-48 rounded-lg">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" @click="startCamera()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Open camera</button>
|
||||
<button type="button" @click="capturePhoto()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Capture</button>
|
||||
<button type="button" @click="clearPhoto()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-for="field in typeConfigs[form.visitor_type]?.fields || []" :key="field.name">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" x-text="field.label"></label>
|
||||
<template x-if="field.type === 'textarea'">
|
||||
<textarea x-model="form.type_fields[field.name]" :required="field.required" rows="2" class="w-full rounded-xl border-slate-200 px-4 py-3 text-lg"></textarea>
|
||||
</template>
|
||||
<template x-if="field.type === 'checkbox'">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" :checked="form.type_fields[field.name]" @change="form.type_fields[field.name] = $event.target.checked" :required="field.required" class="h-5 w-5 rounded">
|
||||
Yes
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="field.type === 'date' || field.type === 'datetime-local' || field.type === 'text'">
|
||||
<input :type="field.type" x-model="form.type_fields[field.name]" :required="field.required" class="w-full rounded-xl border-slate-200 px-4 py-3 text-lg">
|
||||
</template>
|
||||
<template x-if="field.type === 'signature'">
|
||||
<canvas :x-ref="'sig_' + field.name" width="400" height="120" class="w-full rounded-xl border border-slate-200 bg-slate-50"
|
||||
@mousedown="startSign($event, field.name)" @mousemove="drawSign($event, field.name)" @mouseup="endSign(field.name)"
|
||||
@touchstart.prevent="startSign($event, field.name)" @touchmove.prevent="drawSign($event, field.name)" @touchend.prevent="endSign(field.name)"></canvas>
|
||||
<button type="button" @click="clearSign(field.name)" class="mt-1 text-xs text-slate-500">Clear signature</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="typeConfigs[form.visitor_type]?.requires_approval">
|
||||
<p class="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-800">Reception will review your visit before issuing a badge.</p>
|
||||
</template>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<input type="checkbox" x-model="form.policies_accepted" required class="h-5 w-5 rounded">
|
||||
I have read and accept the visitor policies
|
||||
</label>
|
||||
<button type="submit" :disabled="loading" class="w-full rounded-xl bg-teal-600 py-4 text-lg font-bold text-white disabled:opacity-50">
|
||||
<span x-text="loading ? 'Submitting…' : 'Complete check-in'"></span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template x-if="step === 'done'">
|
||||
<div class="rounded-3xl bg-white p-8 text-center text-slate-800 shadow-xl">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-teal-100 text-4xl">✓</div>
|
||||
<h2 class="text-2xl font-bold" x-text="result?.awaiting_approval ? 'Submitted for approval' : 'You\'re checked in!'"></h2>
|
||||
<p class="mt-2 text-slate-600" x-text="result?.visitor_name"></p>
|
||||
<template x-if="!result?.awaiting_approval">
|
||||
<p class="mt-1 text-sm text-slate-500">Badge code: <span class="font-mono font-bold" x-text="result?.badge_code"></span></p>
|
||||
</template>
|
||||
<p class="mt-4 text-sm text-slate-500" x-text="result?.awaiting_approval ? 'Please wait at reception.' : 'Your host has been notified.'"></p>
|
||||
<button @click="reset()" class="mt-8 rounded-xl bg-teal-600 px-8 py-3 font-semibold text-white">Done</button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function kioskFlow() {
|
||||
return {
|
||||
step: 'welcome',
|
||||
loading: false,
|
||||
result: null,
|
||||
timer: null,
|
||||
typeConfigs: @json($typeConfigs),
|
||||
signing: { active: false, field: null },
|
||||
canvases: {},
|
||||
cameraActive: false,
|
||||
cameraStream: null,
|
||||
form: {
|
||||
full_name: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
host_id: '',
|
||||
visitor_type: 'visitor',
|
||||
purpose: '',
|
||||
photo_data: '',
|
||||
policies_accepted: false,
|
||||
type_fields: {},
|
||||
},
|
||||
selectType(type) {
|
||||
this.form.visitor_type = type;
|
||||
this.form.type_fields = {};
|
||||
this.step = 'details';
|
||||
this.resetTimer();
|
||||
},
|
||||
resetTimer() {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.reset(), {{ $resetSeconds }} * 1000);
|
||||
},
|
||||
startTimer() { this.resetTimer(); },
|
||||
reset() {
|
||||
this.stopCamera();
|
||||
this.step = 'welcome';
|
||||
this.result = null;
|
||||
this.form = { full_name: '', company: '', phone: '', email: '', host_id: '', visitor_type: 'visitor', purpose: '', photo_data: '', policies_accepted: false, type_fields: {} };
|
||||
this.resetTimer();
|
||||
},
|
||||
async startCamera() {
|
||||
try {
|
||||
this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
||||
this.$refs.camera.srcObject = this.cameraStream;
|
||||
this.cameraActive = true;
|
||||
} catch (e) {
|
||||
alert('Camera unavailable on this device.');
|
||||
}
|
||||
},
|
||||
capturePhoto() {
|
||||
const video = this.$refs.camera;
|
||||
if (!video?.videoWidth) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
this.form.photo_data = canvas.toDataURL('image/jpeg', 0.85);
|
||||
this.stopCamera();
|
||||
},
|
||||
clearPhoto() {
|
||||
this.form.photo_data = '';
|
||||
},
|
||||
stopCamera() {
|
||||
this.cameraStream?.getTracks().forEach(track => track.stop());
|
||||
this.cameraStream = null;
|
||||
this.cameraActive = false;
|
||||
},
|
||||
startSign(e, field) {
|
||||
this.signing = { active: true, field };
|
||||
this.canvases[field] = e.target;
|
||||
const ctx = e.target.getContext('2d');
|
||||
ctx.strokeStyle = '#0f172a';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const p = this.pointer(e);
|
||||
ctx.moveTo(p.x, p.y);
|
||||
},
|
||||
drawSign(e, field) {
|
||||
if (!this.signing.active || this.signing.field !== field) return;
|
||||
const ctx = this.canvases[field].getContext('2d');
|
||||
const p = this.pointer(e);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
},
|
||||
endSign(field) {
|
||||
if (this.signing.field === field) {
|
||||
this.form.type_fields[field] = this.canvases[field].toDataURL('image/png');
|
||||
}
|
||||
this.signing = { active: false, field: null };
|
||||
},
|
||||
clearSign(field) {
|
||||
const canvas = this.canvases[field];
|
||||
if (!canvas) return;
|
||||
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
|
||||
this.form.type_fields[field] = '';
|
||||
},
|
||||
pointer(e) {
|
||||
const canvas = e.target;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches?.[0];
|
||||
const clientX = touch ? touch.clientX : e.clientX;
|
||||
const clientY = touch ? touch.clientY : e.clientY;
|
||||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||
},
|
||||
async submitCheckIn() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const payload = {
|
||||
...this.form,
|
||||
photo_data: this.form.photo_data || undefined,
|
||||
policies_accepted: this.form.policies_accepted ? 1 : 0,
|
||||
};
|
||||
const res = await fetch('{{ $checkInUrl ?? route('frontdesk.kiosk.check-in') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Check-in failed');
|
||||
const data = await res.json();
|
||||
this.result = data.visit;
|
||||
this.step = 'done';
|
||||
this.resetTimer();
|
||||
} catch (e) {
|
||||
alert('Check-in failed. Please ask reception for help.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<x-app-layout title="Set up Frontdesk">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Welcome to Ladill Frontdesk</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Set up your organization to start managing visitors.</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.onboarding.store') }}" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Primary branch / location</label>
|
||||
<input type="text" name="branch_name" value="{{ old('branch_name', 'Main Office') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($timezones as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', config('app.timezone')) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Badge expiry (hours)</label>
|
||||
<input type="number" name="badge_expiry_hours" value="{{ old('badge_expiry_hours', 8) }}" min="1" max="24"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Kiosk reset (seconds)</label>
|
||||
<input type="number" name="kiosk_reset_seconds" value="{{ old('kiosk_reset_seconds', 120) }}" min="30" max="600"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Visitor policy (optional)</label>
|
||||
<textarea name="visitor_policy" rows="4" class="mt-1 w-full rounded-lg border-slate-300 text-sm"
|
||||
placeholder="Paste your visitor policy text for kiosk display…">{{ old('visitor_policy') }}</textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 px-4 py-3 text-sm font-semibold text-white hover:bg-teal-700">
|
||||
Complete setup
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,54 @@
|
||||
@props(['typeConfigs'])
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Type-specific details</p>
|
||||
|
||||
@foreach ($typeConfigs as $type => $config)
|
||||
@if (count($config['fields']) > 0)
|
||||
<div x-show="selectedType === '{{ $type }}'" x-cloak class="space-y-3">
|
||||
@if ($config['requires_approval'])
|
||||
<p class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">This visitor type requires reception approval before a badge is issued.</p>
|
||||
@endif
|
||||
|
||||
@foreach ($config['fields'] as $field)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ $field['label'] }}</label>
|
||||
|
||||
@if ($field['type'] === 'textarea')
|
||||
<textarea name="type_fields[{{ $field['name'] }}]" rows="2"
|
||||
@if($field['required']) required @endif
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('type_fields.'.$field['name']) }}</textarea>
|
||||
@elseif ($field['type'] === 'checkbox')
|
||||
<label class="mt-1 flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="type_fields[{{ $field['name'] }}]" value="1"
|
||||
@if($field['required']) required @endif
|
||||
@checked(old('type_fields.'.$field['name']))
|
||||
class="rounded border-slate-300 text-teal-600">
|
||||
Yes
|
||||
</label>
|
||||
@elseif ($field['type'] === 'photo')
|
||||
<input type="file" name="type_fields[{{ $field['name'] }}]" accept="image/*"
|
||||
@if($field['required']) required @endif
|
||||
class="mt-1 w-full text-sm">
|
||||
@elseif ($field['type'] === 'date')
|
||||
<input type="date" name="type_fields[{{ $field['name'] }}]"
|
||||
value="{{ old('type_fields.'.$field['name']) }}"
|
||||
@if($field['required']) required @endif
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@elseif ($field['type'] === 'datetime-local')
|
||||
<input type="datetime-local" name="type_fields[{{ $field['name'] }}]"
|
||||
value="{{ old('type_fields.'.$field['name']) }}"
|
||||
@if($field['required']) required @endif
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@else
|
||||
<input type="text" name="type_fields[{{ $field['name'] }}]"
|
||||
value="{{ old('type_fields.'.$field['name']) }}"
|
||||
@if($field['required']) required @endif
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Checked out</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-slate-50 p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-5xl">✓</p>
|
||||
<h1 class="mt-4 text-xl font-semibold text-slate-900">{{ $visit->visitor->full_name }} checked out</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ now()->format('g:i A') }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Visit · {{ $visit->visitor->full_name }}</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="bg-slate-50 p-6">
|
||||
<div class="mx-auto max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 class="text-lg font-semibold text-slate-900">{{ $visit->visitor->full_name }}</h1>
|
||||
<p class="text-sm text-slate-500">{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}</p>
|
||||
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Status</dt><dd class="capitalize font-medium">{{ str_replace('_', ' ', $visit->status) }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Host</dt><dd>{{ $visit->host?->name ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Checked in</dt><dd>{{ $visit->checked_in_at?->format('M j, g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Badge expires</dt><dd>{{ $visit->badge_expires_at?->format('g:i A') ?? '—' }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-6 flex justify-center">{!! $qrSvg !!}</div>
|
||||
|
||||
@if ($visit->isInside())
|
||||
<form method="POST" action="{{ route('frontdesk.qr.checkout', $visit->qr_token) }}" class="mt-6">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg bg-slate-900 py-3 text-sm font-semibold text-white">Quick check-out</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<x-app-layout title="Reports">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900 dark:text-slate-100">Reports</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ $organization->name }}</p>
|
||||
</div>
|
||||
@if ($canExport)
|
||||
<a href="{{ route('frontdesk.reports.export', request()->query()) }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Export CSV</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mt-4 flex flex-wrap gap-2">
|
||||
<input type="date" name="from" value="{{ $from }}" class="rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
<input type="date" name="to" value="{{ $to }}" class="rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-4 py-2 text-sm dark:border-slate-600">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($summary as $key => $value)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">{{ str_replace('_', ' ', $key) }}</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100">{{ $value }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Peak check-in hours</h2>
|
||||
<div class="mt-4 flex items-end gap-1 h-40">
|
||||
@php $maxPeak = max(1, collect($peakHours)->max('count')); @endphp
|
||||
@foreach ($peakHours as $row)
|
||||
<div class="flex-1 flex flex-col items-center gap-1">
|
||||
<div class="w-full rounded-t bg-teal-500/80" style="height: {{ max(4, ($row['count'] / $maxPeak) * 100) }}%"></div>
|
||||
<span class="text-[10px] text-slate-400">{{ $row['hour'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Security incidents</h2>
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
@foreach ($security as $key => $count)
|
||||
<div class="flex justify-between"><dt class="text-slate-500">{{ str_replace('_', ' ', $key) }}</dt><dd class="font-medium">{{ $count }}</dd></div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Visits by department</h2>
|
||||
<table class="mt-4 min-w-full text-sm">
|
||||
@forelse ($departments as $row)
|
||||
<tr class="border-t border-slate-100 dark:border-slate-800"><td class="py-2">{{ $row->department }}</td><td class="py-2 text-right font-medium">{{ $row->count }}</td></tr>
|
||||
@empty
|
||||
<tr><td class="py-4 text-slate-500">No data for this period.</td></tr>
|
||||
@endforelse
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Frequent visitors</h2>
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
@forelse ($frequentVisitors as $visitor)
|
||||
<li class="flex justify-between"><span>{{ $visitor->full_name }}</span><span class="text-slate-500">{{ $visitor->visit_count }} visits</span></li>
|
||||
@empty
|
||||
<li class="text-slate-500">No frequent visitors yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Evacuation badges · {{ $organization->name }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.item { page-break-inside: avoid; }
|
||||
@media print { .toolbar { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar" style="margin-bottom: 1rem;">
|
||||
<strong>{{ $visits->count() }} badge(s)</strong> —
|
||||
<button onclick="window.print()">Print all</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@foreach ($rendered as $html)
|
||||
<div class="item">{!! $html !!}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Emergency Evacuation Report · {{ $organization->name }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 2rem; color: #111; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.meta { color: #666; margin-bottom: 2rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; font-size: 0.875rem; }
|
||||
th { background: #f5f5f5; }
|
||||
@media print { .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="no-print" onclick="window.print()">Print report</button>
|
||||
<h1>Emergency Evacuation Report</h1>
|
||||
<p class="meta">{{ $organization->name }} · Generated {{ now()->format('M j, Y g:i A') }} · {{ $occupancy->count() }} occupants</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Host</th>
|
||||
<th>Branch</th>
|
||||
<th>Checked in</th>
|
||||
<th>Phone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($occupancy as $visit)
|
||||
<tr>
|
||||
<td>{{ $visit->visitor->full_name }}</td>
|
||||
<td>{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}</td>
|
||||
<td>{{ $visit->host?->name ?? '—' }}</td>
|
||||
<td>{{ $visit->branch?->name ?? '—' }}</td>
|
||||
<td>{{ $visit->checked_in_at?->format('g:i A') }}</td>
|
||||
<td>{{ $visit->visitor->phone ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-app-layout title="Security Dashboard">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Security dashboard</h1>
|
||||
<p class="text-sm text-slate-500">{{ $occupancy->count() }} people currently inside</p>
|
||||
</div>
|
||||
<a href="{{ route('frontdesk.security.evacuation') }}" class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100">
|
||||
Evacuation report
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.security.verify') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Verify badge
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($expiredBadges->isNotEmpty())
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
{{ $expiredBadges->count() }} visitor(s) have expired badges but are still checked in.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Visitor</th>
|
||||
<th class="px-4 py-3">Host</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Checked in</th>
|
||||
<th class="px-4 py-3">Badge</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach ($occupancy as $visit)
|
||||
<tr class="{{ $visit->isBadgeExpired() ? 'bg-amber-50' : '' }}">
|
||||
<td class="px-4 py-3 font-medium">{{ $visit->visitor->full_name }}</td>
|
||||
<td class="px-4 py-3">{{ $visit->host?->name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 capitalize">{{ str_replace('_', ' ', $visit->visitor_type) }}</td>
|
||||
<td class="px-4 py-3">{{ $visit->checked_in_at?->format('g:i A') }}</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $visit->badge_code }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($canCheckout)
|
||||
<form method="POST" action="{{ route('frontdesk.security.checkout', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-teal-600 hover:text-teal-800">Check out</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,46 @@
|
||||
<x-app-layout title="Verify badge">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Badge verification</h1>
|
||||
<p class="text-sm text-slate-500">Scan or enter a badge code / QR token</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.security.verify.lookup') }}" class="mt-6 flex gap-2">
|
||||
@csrf
|
||||
<input type="text" name="lookup" value="{{ $lookup ?? '' }}" required autofocus
|
||||
placeholder="Badge code or QR token"
|
||||
class="flex-1 rounded-lg border-slate-300 text-sm uppercase">
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Verify</button>
|
||||
</form>
|
||||
|
||||
@isset($lookup)
|
||||
@if ($visit)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ $visit->visitor->full_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }} · {{ $visit->host?->name }}</p>
|
||||
</div>
|
||||
@if ($visit->isInside() && ! $visit->isBadgeExpired())
|
||||
<span class="rounded-full bg-teal-50 px-3 py-1 text-xs font-semibold text-teal-700">Valid</span>
|
||||
@elseif ($visit->isInside() && $visit->isBadgeExpired())
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Expired badge</span>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{{ str_replace('_', ' ', $visit->status) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Badge</dt><dd class="font-mono font-medium">{{ $visit->badge_code }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Checked in</dt><dd>{{ $visit->checked_in_at?->format('M j, Y g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Expires</dt><dd>{{ $visit->badge_expires_at?->format('g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Branch</dt><dd>{{ $visit->branch?->name ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Watchlist</dt><dd class="capitalize">{{ str_replace('_', ' ', $visit->visitor->watchlist_status) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}" class="mt-4 inline-block text-sm font-medium text-teal-600 hover:text-teal-700">View full visit record</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">No matching visit found for “{{ $lookup }}”.</p>
|
||||
@endif
|
||||
@endisset
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,121 @@
|
||||
<x-app-layout title="Settings">
|
||||
@php
|
||||
$settings = $organization->settings ?? [];
|
||||
@endphp
|
||||
|
||||
<h1 class="text-xl font-semibold text-slate-900">Settings</h1>
|
||||
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.settings.update') }}" class="mt-6 max-w-2xl space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Organization</h2>
|
||||
@if ($canManage)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $organization->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (timezone_identifiers_list() as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@else
|
||||
<dl class="text-sm space-y-2">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Name</dt><dd>{{ $organization->name }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Timezone</dt><dd>{{ $organization->timezone }}</dd></div>
|
||||
</dl>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($canManage)
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Reception & kiosk</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Badge expiry (hours)</label>
|
||||
<input type="number" name="badge_expiry_hours" value="{{ old('badge_expiry_hours', $settings['badge_expiry_hours'] ?? 8) }}" min="1" max="24" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Kiosk inactivity reset (seconds)</label>
|
||||
<input type="number" name="kiosk_reset_seconds" value="{{ old('kiosk_reset_seconds', $settings['kiosk_reset_seconds'] ?? 120) }}" min="30" max="600" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contractor badge expiry (hours)</label>
|
||||
<input type="number" name="contractor_badge_expiry_hours" value="{{ old('contractor_badge_expiry_hours', data_get($settings, 'type_badge_expiry_hours.contractor', 12)) }}" min="1" max="24" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Visitor policy</label>
|
||||
<textarea name="visitor_policy" rows="4" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('visitor_policy', $settings['visitor_policy'] ?? '') }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700">Notification channels</p>
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
@foreach ($notificationChannels as $key => $label)
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="notification_channels[]" value="{{ $key }}"
|
||||
@checked(in_array($key, old('notification_channels', $settings['notification_channels'] ?? ['email'])))>
|
||||
{{ $label }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700">Notification events</p>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
@foreach ($notificationEvents as $key => $label)
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="notification_events[{{ $key }}]" value="1"
|
||||
@checked(old("notification_events.{$key}", data_get($settings, "notification_events.{$key}", true)))>
|
||||
{{ $label }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Daily report recipients</label>
|
||||
<input type="text" name="report_daily_recipients" value="{{ old('report_daily_recipients', implode(', ', $settings['report_daily_recipients'] ?? [])) }}" placeholder="admin@example.com, security@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Comma-separated emails for the daily summary report.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">Save settings</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.branches.index') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Branches</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $branchCount }} branch{{ $branchCount === 1 ? '' : 'es' }}</p>
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.members.index') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Team members</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Manage roles and access</p>
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('frontdesk.kiosk') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Kiosk mode</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Open self-service check-in</p>
|
||||
</a>
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.settings.badge') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Badge template</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Layout, photo, and QR settings</p>
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.integrations.edit') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Integrations</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Webhooks and calendar feeds</p>
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.devices.index') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-teal-300">
|
||||
<h3 class="font-semibold text-slate-900">Devices</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Kiosks, printers, and hardware</p>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,66 @@
|
||||
<x-app-layout :title="'Check in '.$visitor->full_name">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Quick check-in</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Returning visitor · {{ $visitor->full_name }}
|
||||
@if ($visitor->is_frequent)
|
||||
<span class="rounded bg-teal-50 px-2 py-0.5 text-xs text-teal-700">Frequent</span>
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<dl class="mt-4 rounded-xl border border-slate-200 bg-white p-4 text-sm">
|
||||
<div class="flex justify-between py-1"><dt class="text-slate-500">Company</dt><dd>{{ $visitor->company ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between py-1"><dt class="text-slate-500">Phone</dt><dd>{{ $visitor->phone ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between py-1"><dt class="text-slate-500">Email</dt><dd>{{ $visitor->email ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between py-1"><dt class="text-slate-500">Total visits</dt><dd>{{ $visitor->visit_count }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.visitors.check-in.store', $visitor) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="branch_id" class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" id="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select branch…</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="visitor_type" class="block text-sm font-medium text-slate-700">Visitor type</label>
|
||||
<select name="visitor_type" id="visitor_type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('frontdesk.visitor_types') as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="host_id" class="block text-sm font-medium text-slate-700">Host</label>
|
||||
<select name="host_id" id="host_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select host…</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}">{{ $host->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purpose" class="block text-sm font-medium text-slate-700">Purpose</label>
|
||||
<input type="text" name="purpose" id="purpose" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="policies_accepted" value="1" required class="mt-1 rounded border-slate-300 text-teal-600">
|
||||
Visitor has read and accepted visitor policies.
|
||||
</label>
|
||||
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 px-4 py-3 text-sm font-semibold text-white hover:bg-teal-700">
|
||||
Check in now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<x-app-layout title="Visitors">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Visitor database</h1>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mt-4">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search by name…" class="w-full max-w-md rounded-lg border-slate-300 text-sm">
|
||||
</form>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Company</th>
|
||||
<th class="px-4 py-3">Visits</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($visitors as $visitor)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><a href="{{ route('frontdesk.visitors.show', $visitor) }}" class="font-medium text-teal-700">{{ $visitor->full_name }}</a></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $visitor->company ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $visitor->visit_count }}</td>
|
||||
<td class="px-4 py-3 capitalize text-slate-600">{{ str_replace('_', ' ', $visitor->watchlist_status) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">No visitors yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $visitors->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,106 @@
|
||||
<x-app-layout :title="$visitor->full_name">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">{{ $visitor->full_name }}</h1>
|
||||
<p class="text-sm text-slate-500">{{ $visitor->company }} · {{ $visitor->visit_count }} visits · {{ ucfirst(str_replace('_', ' ', $visitor->watchlist_status)) }}</p>
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.visitors.check-in', $visitor) }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Quick check-in</a>
|
||||
<form method="POST" action="{{ route('frontdesk.visitors.destroy', $visitor) }}" onsubmit="return confirm('Archive this visitor?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-700 hover:bg-red-50">Archive</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('frontdesk.visitors.update', $visitor) }}" enctype="multipart/form-data" class="mt-4 max-w-xl space-y-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
@csrf @method('PATCH')
|
||||
<h2 class="text-sm font-semibold text-slate-900">Profile</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_name" value="{{ $visitor->full_name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Company</label>
|
||||
<input type="text" name="company" value="{{ $visitor->company }}" 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 type="tel" name="phone" value="{{ $visitor->phone }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ $visitor->email }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ $visitor->notes }}</textarea>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Photo</label>
|
||||
<input type="file" name="photo" accept="image/*" class="mt-1 w-full text-sm">
|
||||
@if ($visitor->photo_path)
|
||||
<p class="mt-1 text-xs text-slate-500">Current photo on file</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">ID document</label>
|
||||
<input type="file" name="id_document" accept=".pdf,image/*" class="mt-1 w-full text-sm">
|
||||
@if ($visitor->id_document_path)
|
||||
<p class="mt-1 text-xs text-slate-500">Current document on file</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-medium text-white">Save profile</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.visitors.watchlist', $visitor) }}" class="mt-4 max-w-md space-y-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
@csrf @method('PATCH')
|
||||
<h2 class="text-sm font-semibold text-slate-900">Watchlist</h2>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Watchlist status</label>
|
||||
<select name="watchlist_status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($watchlistStatuses as $key => $label)
|
||||
<option value="{{ $key }}" @selected($visitor->watchlist_status === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-700">Update watchlist</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($activity->isNotEmpty())
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Activity</h2>
|
||||
</div>
|
||||
@foreach ($activity as $log)
|
||||
<div class="border-b border-slate-50 px-5 py-3 last:border-0 text-sm">
|
||||
<p class="font-medium text-slate-800">{{ $auditActions[$log->action] ?? $log->action }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $log->created_at?->format('M j, Y g:i A') }} · {{ $log->actor_ref ?? 'System' }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Visit history</h2>
|
||||
</div>
|
||||
@forelse ($visitor->visits as $visit)
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}" class="flex items-center justify-between border-b border-slate-50 px-5 py-3 last:border-0 hover:bg-slate-50">
|
||||
<div>
|
||||
<p class="text-sm text-slate-800">{{ $visit->purpose ?? ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $visit->checked_in_at?->format('M j, Y') ?? $visit->scheduled_at?->format('M j, Y g:i A') ?? $visit->created_at->format('M j, Y') }}</p>
|
||||
</div>
|
||||
<span class="text-xs capitalize text-slate-500">{{ str_replace('_', ' ', $visit->status) }}</span>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No visits recorded.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,39 @@
|
||||
<x-app-layout title="Visit calendar">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Visit calendar</h1>
|
||||
<p class="text-sm text-slate-500">{{ $start->format('M j') }} – {{ $end->format('M j, Y') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('frontdesk.visits.calendar', ['start' => $start->copy()->subWeek()->toDateString()]) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Previous</a>
|
||||
<a href="{{ route('frontdesk.visits.calendar', ['start' => $start->copy()->addWeek()->toDateString()]) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Next</a>
|
||||
<a href="{{ route('frontdesk.visits.schedule') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Schedule</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-7">
|
||||
@for ($day = $start->copy(); $day->lte($end); $day->addDay())
|
||||
@php $dateKey = $day->toDateString(); @endphp
|
||||
<section class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-3 py-2">
|
||||
<p class="text-xs font-semibold uppercase text-slate-500">{{ $day->format('D') }}</p>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $day->format('M j') }}</p>
|
||||
</div>
|
||||
<div class="space-y-1 p-2">
|
||||
@forelse ($visits->get($dateKey, collect()) as $visit)
|
||||
<a href="{{ route('frontdesk.visits.show', $visit) }}"
|
||||
class="block rounded-lg px-2 py-1.5 text-xs hover:bg-slate-50 @if($visit->status === 'overdue') border border-amber-200 bg-amber-50 @endif">
|
||||
<p class="font-medium text-slate-800">{{ $visit->scheduled_at->format('g:i A') }}</p>
|
||||
<p class="truncate text-slate-600">{{ $visit->visitor->full_name }}</p>
|
||||
<p class="capitalize text-slate-400">{{ str_replace('_', ' ', $visit->status) }}</p>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-2 py-4 text-center text-xs text-slate-400">No visits</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endfor
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,103 @@
|
||||
<x-app-layout title="Check in visitor">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Check in visitor</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Register a new visit or search for a returning visitor.</p>
|
||||
|
||||
<form method="GET" class="mt-4">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search returning visitors…"
|
||||
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-teal-500 focus:ring-teal-500">
|
||||
</form>
|
||||
|
||||
@if ($returningVisitors->isNotEmpty())
|
||||
<div class="mt-4 rounded-xl border border-slate-200 bg-white">
|
||||
<p class="border-b border-slate-100 px-4 py-2 text-xs font-medium uppercase text-slate-500">Returning visitors</p>
|
||||
@foreach ($returningVisitors as $visitor)
|
||||
<div class="flex items-center justify-between border-b border-slate-50 px-4 py-3 last:border-0">
|
||||
<button type="button" onclick="document.getElementById('visitor_id').value='{{ $visitor->id }}'; document.getElementById('full_name').value='{{ $visitor->full_name }}'; document.getElementById('company').value='{{ $visitor->company }}'; document.getElementById('phone').value='{{ $visitor->phone }}'; document.getElementById('email').value='{{ $visitor->email }}';"
|
||||
class="flex flex-1 items-center gap-3 text-left hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-800">{{ $visitor->full_name }}</span>
|
||||
@if ($visitor->is_frequent)
|
||||
<span class="rounded bg-teal-50 px-2 py-0.5 text-xs text-teal-700">Frequent</span>
|
||||
@endif
|
||||
</button>
|
||||
<a href="{{ route('frontdesk.visitors.check-in', $visitor) }}" class="shrink-0 text-xs font-medium text-teal-600 hover:text-teal-700">Quick check-in</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.visits.store') }}" enctype="multipart/form-data"
|
||||
x-data="{ selectedType: '{{ old('visitor_type', 'visitor') }}' }"
|
||||
class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<input type="hidden" name="visitor_id" id="visitor_id">
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_name" id="full_name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="branch_id" class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" id="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select branch…</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="visitor_type" class="block text-sm font-medium text-slate-700">Visitor type</label>
|
||||
<select name="visitor_type" id="visitor_type" x-model="selectedType" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('frontdesk.visitor_types') as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('frontdesk.partials.type-fields', ['typeConfigs' => $typeConfigs])
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="company" class="block text-sm font-medium text-slate-700">Company</label>
|
||||
<input type="text" name="company" id="company" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-slate-700">Phone</label>
|
||||
<input type="tel" name="phone" id="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" id="email" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="host_id" class="block text-sm font-medium text-slate-700">Host</label>
|
||||
<select name="host_id" id="host_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select host…</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}">{{ $host->name }} @if($host->department)({{ $host->department }})@endif</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purpose" class="block text-sm font-medium text-slate-700">Purpose of visit</label>
|
||||
<input type="text" name="purpose" id="purpose" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="policies_accepted" value="1" required class="mt-1 rounded border-slate-300 text-teal-600">
|
||||
Visitor has read and accepted visitor policies.
|
||||
</label>
|
||||
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 px-4 py-3 text-sm font-semibold text-white hover:bg-teal-700">
|
||||
Check in & print badge
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,49 @@
|
||||
<x-app-layout title="Visit history">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Visits</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('frontdesk.visits.schedule') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Schedule</a>
|
||||
<a href="{{ route('frontdesk.visits.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Check in</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mt-4 flex gap-2">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search visitors…" class="rounded-lg border-slate-300 text-sm">
|
||||
<select name="status" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All statuses</option>
|
||||
@foreach (config('frontdesk.visit_statuses') as $key => $label)
|
||||
<option value="{{ $key }}" @selected(request('status') === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-4 py-2 text-sm">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Visitor</th>
|
||||
<th class="px-4 py-3">Host</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($visits as $visit)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><a href="{{ route('frontdesk.visits.show', $visit) }}" class="font-medium text-teal-700">{{ $visit->visitor->full_name }}</a></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $visit->host?->name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 capitalize text-slate-600">{{ str_replace('_', ' ', $visit->visitor_type) }}</td>
|
||||
<td class="px-4 py-3 capitalize text-slate-600">{{ str_replace('_', ' ', $visit->status) }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ $visit->checked_in_at?->format('M j, g:i A') ?? $visit->scheduled_at?->format('M j, g:i A') ?? $visit->created_at->format('M j, g:i A') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-400">No visits yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $visits->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,102 @@
|
||||
<x-app-layout title="Schedule visit">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Pre-register visitor</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Schedule an expected arrival without checking in.</p>
|
||||
|
||||
<form method="GET" class="mt-4">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search returning visitors…"
|
||||
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-teal-500 focus:ring-teal-500">
|
||||
</form>
|
||||
|
||||
@if ($returningVisitors->isNotEmpty())
|
||||
<div class="mt-4 rounded-xl border border-slate-200 bg-white">
|
||||
<p class="border-b border-slate-100 px-4 py-2 text-xs font-medium uppercase text-slate-500">Returning visitors</p>
|
||||
@foreach ($returningVisitors as $visitor)
|
||||
<button type="button" onclick="document.getElementById('visitor_id').value='{{ $visitor->id }}'; document.getElementById('full_name').value='{{ $visitor->full_name }}'; document.getElementById('company').value='{{ $visitor->company }}'; document.getElementById('phone').value='{{ $visitor->phone }}'; document.getElementById('email').value='{{ $visitor->email }}';"
|
||||
class="flex w-full items-center gap-3 border-b border-slate-50 px-4 py-3 text-left last:border-0 hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-800">{{ $visitor->full_name }}</span>
|
||||
@if ($visitor->is_frequent)
|
||||
<span class="rounded bg-teal-50 px-2 py-0.5 text-xs text-teal-700">Frequent</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.visits.schedule.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<input type="hidden" name="visitor_id" id="visitor_id">
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_name" id="full_name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="scheduled_at" class="block text-sm font-medium text-slate-700">Expected arrival</label>
|
||||
<input type="datetime-local" name="scheduled_at" id="scheduled_at" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="visitor_type" class="block text-sm font-medium text-slate-700">Visitor type</label>
|
||||
<select name="visitor_type" id="visitor_type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (config('frontdesk.visitor_types') as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="branch_id" class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" id="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select branch…</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}">{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="host_id" class="block text-sm font-medium text-slate-700">Host</label>
|
||||
<select name="host_id" id="host_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select host…</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}">{{ $host->name }} @if($host->department)({{ $host->department }})@endif</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="company" class="block text-sm font-medium text-slate-700">Company</label>
|
||||
<input type="text" name="company" id="company" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-slate-700">Phone</label>
|
||||
<input type="tel" name="phone" id="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" id="email" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purpose" class="block text-sm font-medium text-slate-700">Purpose of visit</label>
|
||||
<input type="text" name="purpose" id="purpose" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" id="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full rounded-lg bg-teal-600 px-4 py-3 text-sm font-semibold text-white hover:bg-teal-700">
|
||||
Schedule visit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,99 @@
|
||||
<x-app-layout :title="$visit->visitor->full_name">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">{{ $visit->visitor->full_name }}</h1>
|
||||
<p class="text-sm text-slate-500">{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }} · Badge {{ $visit->badge_code }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($visit->isInside())
|
||||
<a href="{{ route('frontdesk.visits.badge', $visit) }}" target="_blank" class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Print badge</a>
|
||||
<a href="{{ route('frontdesk.visits.badge.preview', $visit) }}" target="_blank" class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Preview</a>
|
||||
<form method="POST" action="{{ route('frontdesk.visits.checkout', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-800">Check out</button>
|
||||
</form>
|
||||
@elseif ($canManage && $visit->awaitingApproval())
|
||||
<form method="POST" action="{{ route('frontdesk.visits.approve', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-3 py-2 text-sm font-medium text-white hover:bg-teal-700">Approve & check in</button>
|
||||
</form>
|
||||
@elseif ($canManage && $visit->canActivateCheckIn())
|
||||
<form method="POST" action="{{ route('frontdesk.visits.activate', $visit) }}" class="flex items-center gap-2">
|
||||
@csrf
|
||||
<label class="flex items-center gap-1 text-xs text-slate-600">
|
||||
<input type="checkbox" name="policies_accepted" value="1" required class="rounded border-slate-300 text-teal-600">
|
||||
Policies accepted
|
||||
</label>
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-3 py-2 text-sm font-medium text-white hover:bg-teal-700">Check in</button>
|
||||
</form>
|
||||
@elseif ($canManage && ! $visit->isInside())
|
||||
<form method="POST" action="{{ route('frontdesk.visits.destroy', $visit) }}" onsubmit="return confirm('Archive this visit?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 px-3 py-2 text-sm text-red-700 hover:bg-red-50">Archive</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($visit->awaitingApproval())
|
||||
<p class="mt-4 rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-800">Awaiting reception approval before badge issue.</p>
|
||||
@endif
|
||||
|
||||
@if ($canManage && $visit->isPending() && ! $visit->awaitingApproval())
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if (in_array($visit->status, ['expected', 'scheduled', 'overdue'], true))
|
||||
<form method="POST" action="{{ route('frontdesk.visits.waiting', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Mark waiting</button>
|
||||
</form>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('frontdesk.visits.cancel', $visit) }}" onsubmit="return confirm('Cancel this visit?')">
|
||||
@csrf
|
||||
<input type="hidden" name="reason" value="Cancelled by reception">
|
||||
<button type="submit" class="rounded-lg border border-red-200 px-3 py-2 text-sm text-red-700 hover:bg-red-50">Cancel visit</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php $typeDetails = $visit->typeDetailEntries(); @endphp
|
||||
@if (count($typeDetails) > 0)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }} details</h2>
|
||||
<dl class="mt-3 space-y-2 text-sm">
|
||||
@foreach ($typeDetails as $label => $value)
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-slate-500">{{ $label }}</dt>
|
||||
<dd class="text-right font-medium text-slate-800">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Visit details</h2>
|
||||
<dl class="mt-3 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Status</dt><dd class="font-medium capitalize @if($visit->status === 'overdue') text-amber-700 @endif">{{ str_replace('_', ' ', $visit->status) }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Scheduled</dt><dd>{{ $visit->scheduled_at?->format('M j, Y g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Checked in</dt><dd>{{ $visit->checked_in_at?->format('M j, Y g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Checked out</dt><dd>{{ $visit->checked_out_at?->format('M j, Y g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Badge expires</dt><dd>{{ $visit->badge_expires_at?->format('g:i A') ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Purpose</dt><dd>{{ $visit->purpose ?? '—' }}</dd></div>
|
||||
@if ($visit->notes)
|
||||
<div><dt class="text-slate-500">Notes</dt><dd class="mt-1 whitespace-pre-wrap text-slate-700">{{ $visit->notes }}</dd></div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Host</h2>
|
||||
<dl class="mt-3 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Name</dt><dd>{{ $visit->host?->name ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Department</dt><dd>{{ $visit->host?->department ?? '—' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Office</dt><dd>{{ $visit->host?->office ?? '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-app-layout title="Add watchlist entry">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add watchlist entry</h1>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.watchlist.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Existing visitor</label>
|
||||
<select name="visitor_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">New / manual entry…</option>
|
||||
@foreach ($visitors as $visitor)
|
||||
<option value="{{ $visitor->id }}">{{ $visitor->full_name }} @if($visitor->company)({{ $visitor->company }})@endif</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_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">Company</label>
|
||||
<input type="text" name="company" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($statuses as $key => $label)
|
||||
@if ($key !== 'allowed')
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Reason</label>
|
||||
<textarea name="reason" rows="3" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">Save entry</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,66 @@
|
||||
<x-app-layout title="Watchlist">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Watchlist</h1>
|
||||
<p class="text-sm text-slate-500">Flagged and blocked visitors</p>
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<a href="{{ route('frontdesk.watchlist.create') }}" class="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">Add entry</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mt-4 flex flex-wrap gap-2">
|
||||
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search name or company…" class="rounded-lg border-slate-300 text-sm">
|
||||
<select name="status" class="rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All statuses</option>
|
||||
@foreach ($statuses as $key => $label)
|
||||
@if ($key !== 'allowed')
|
||||
<option value="{{ $key }}" @selected(request('status') === $key)>{{ $label }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-4 py-2 text-sm">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Company</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Reason</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($entries as $entry)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">
|
||||
@if ($entry->visitor)
|
||||
<a href="{{ route('frontdesk.visitors.show', $entry->visitor) }}" class="text-teal-700">{{ $entry->full_name }}</a>
|
||||
@else
|
||||
{{ $entry->full_name }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $entry->company ?? '—' }}</td>
|
||||
<td class="px-4 py-3 capitalize">{{ str_replace('_', ' ', $entry->status) }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ Str::limit($entry->reason, 60) ?: '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($canManage)
|
||||
<form method="POST" action="{{ route('frontdesk.watchlist.destroy', $entry) }}" onsubmit="return confirm('Remove this watchlist entry?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-800">Remove</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-400">No watchlist entries.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $entries->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -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 contacts, leads, your deal pipeline, logging activities, or sending an email or SMS…";
|
||||
$afiaSuggestions = [
|
||||
'How do I add a contact?',
|
||||
'How does the deal pipeline work?',
|
||||
'How do I convert a lead?',
|
||||
'How do I email a contact?',
|
||||
];
|
||||
@endphp
|
||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ route('crm.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">CRM 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,53 @@
|
||||
@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
|
||||
));
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
||||
<button type="button" @click="appsOpen = !appsOpen"
|
||||
@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="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex flex-col items-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>
|
||||
</div>
|
||||
@endif
|
||||
@@ -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,114 @@
|
||||
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700">
|
||||
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6">
|
||||
<a href="{{ route('frontdesk.dashboard') }}" class="flex items-center">
|
||||
<img src="{{ asset('images/logo/ladillfrontdesk-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillfrontdesk-logo.svg')) ?: '1' }}" alt="Ladill Frontdesk" class="h-6 w-auto">
|
||||
</a>
|
||||
</div>
|
||||
@php
|
||||
$member = auth()->user()
|
||||
? app(\App\Services\Frontdesk\OrganizationResolver::class)->memberFor(auth()->user())
|
||||
: null;
|
||||
$permissions = app(\App\Services\Frontdesk\FrontdeskPermissions::class);
|
||||
$linkedHost = auth()->user()
|
||||
? app(\App\Services\Frontdesk\OrganizationResolver::class)->hostFor(auth()->user())
|
||||
: null;
|
||||
|
||||
$nav = [
|
||||
['name' => 'Reception', 'route' => route('frontdesk.dashboard'), 'active' => request()->routeIs('frontdesk.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' => 'Check-in', 'route' => route('frontdesk.visits.create'), 'active' => request()->routeIs('frontdesk.visits.create'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
|
||||
['name' => 'Visits', 'route' => route('frontdesk.visits.index'), 'active' => request()->routeIs('frontdesk.visits.*') && !request()->routeIs('frontdesk.visits.create') && !request()->routeIs('frontdesk.visits.schedule*'),
|
||||
'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" />'],
|
||||
['name' => 'Schedule', 'route' => route('frontdesk.visits.schedule'), 'active' => request()->routeIs('frontdesk.visits.schedule*'),
|
||||
'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" />'],
|
||||
['name' => 'Calendar', 'route' => route('frontdesk.visits.calendar'), 'active' => request()->routeIs('frontdesk.visits.calendar'),
|
||||
'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" />'],
|
||||
['name' => 'Visitors', 'route' => route('frontdesk.visitors.index'), 'active' => request()->routeIs('frontdesk.visitors.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
|
||||
['name' => 'Hosts', 'route' => route('frontdesk.hosts.index'), 'active' => request()->routeIs('frontdesk.hosts.*'),
|
||||
'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" />'],
|
||||
['name' => 'Kiosk', 'route' => route('frontdesk.kiosk'), 'active' => request()->routeIs('frontdesk.kiosk') && !request()->routeIs('frontdesk.kiosk.device*'),
|
||||
'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, 'host.portal') || $linkedHost) {
|
||||
$nav[] = ['name' => 'Host portal', 'route' => route('frontdesk.host.index'), 'active' => request()->routeIs('frontdesk.host.*'),
|
||||
'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" />'];
|
||||
}
|
||||
|
||||
$nav = array_merge($nav, [
|
||||
['name' => 'Security', 'route' => route('frontdesk.security.index'), 'active' => request()->routeIs('frontdesk.security.*'),
|
||||
'icon' => '<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" />'],
|
||||
]);
|
||||
|
||||
if ($permissions->can($member, 'reports.view')) {
|
||||
$nav[] = ['name' => 'Reports', 'route' => route('frontdesk.reports.index'), 'active' => request()->routeIs('frontdesk.reports.*'),
|
||||
'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" />'];
|
||||
}
|
||||
|
||||
if ($permissions->can($member, 'devices.view')) {
|
||||
$nav[] = ['name' => 'Devices', 'route' => route('frontdesk.devices.index'), 'active' => request()->routeIs('frontdesk.devices.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21" />'];
|
||||
}
|
||||
|
||||
if ($permissions->can($member, 'watchlist.view')) {
|
||||
$nav[] = ['name' => 'Watchlist', 'route' => route('frontdesk.watchlist.index'), 'active' => request()->routeIs('frontdesk.watchlist.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />'];
|
||||
}
|
||||
|
||||
if ($permissions->can($member, 'audit.view')) {
|
||||
$nav[] = ['name' => 'Audit log', 'route' => route('frontdesk.audit.index'), 'active' => request()->routeIs('frontdesk.audit.*'),
|
||||
'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" />'];
|
||||
}
|
||||
|
||||
$complianceNav = [];
|
||||
if ($permissions->can($member, 'compliance.restore')) {
|
||||
$complianceNav[] = ['name' => 'Recovery', 'route' => route('frontdesk.compliance.recovery'), 'active' => request()->routeIs('frontdesk.compliance.*')];
|
||||
}
|
||||
|
||||
$adminNav = [];
|
||||
if ($permissions->can($member, 'admin.branches.view')) {
|
||||
$adminNav[] = ['name' => 'Branches', 'route' => route('frontdesk.branches.index'), 'active' => request()->routeIs('frontdesk.branches.*') || request()->routeIs('frontdesk.buildings.*') || request()->routeIs('frontdesk.desks.*')];
|
||||
}
|
||||
if ($permissions->can($member, 'admin.members.view')) {
|
||||
$adminNav[] = ['name' => 'Team', 'route' => route('frontdesk.members.index'), 'active' => request()->routeIs('frontdesk.members.*')];
|
||||
}
|
||||
@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-teal-50 text-teal-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-teal-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@if (count($adminNav) > 0 || count($complianceNav) > 0)
|
||||
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Compliance</p>
|
||||
@foreach ($complianceNav 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-teal-50 text-teal-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if (count($adminNav) > 0)
|
||||
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Administration</p>
|
||||
@foreach ($adminNav 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-teal-50 text-teal-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
|
||||
<a href="{{ route('frontdesk.settings') }}"
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('frontdesk.settings*') ? 'bg-teal-50 text-teal-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('frontdesk.settings*') ? 'text-teal-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<span class="flex-1 truncate">Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</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,41 @@
|
||||
{{--
|
||||
Standard desktop top-right widgets: 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.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" 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,37 @@
|
||||
@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 dark:border-slate-800 dark:bg-slate-900 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: current page title --}}
|
||||
<h1 class="truncate text-base font-semibold text-slate-900 lg:hidden">{{ $heading ?? 'Ladill Frontdesk' }}</h1>
|
||||
|
||||
{{-- 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-teal-300 focus:bg-white focus:ring-2 focus:ring-teal-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">
|
||||
<button type="button" @click="dark = !dark" class="hidden lg:inline-flex text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200" aria-label="Toggle dark mode">
|
||||
<svg x-show="!dark" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/></svg>
|
||||
<svg x-show="dark" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m8.66-9h-1M4.34 12h-1M18.364 5.636l-.707.707M6.343 17.657l-.707.707M18.364 18.364l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z"/></svg>
|
||||
</button>
|
||||
@includeIf('partials.topbar-widgets-prepend')
|
||||
@include('partials.topbar-desktop-widgets', ['user' => $user, 'showUser' => true])
|
||||
@includeIf('partials.topbar-widgets-append')
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,70 @@
|
||||
@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')
|
||||
@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,23 @@
|
||||
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
|
||||
@php
|
||||
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
|
||||
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
|
||||
@endphp
|
||||
@if ($balanceUrl)
|
||||
<a href="{{ ladill_account_url('/wallet') }}"
|
||||
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
|
||||
class="mx-1 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
|
||||
Reference in New Issue
Block a user