@extends('admin.layouts.app') @php $dash = '—'; $text = fn ($v) => ($v === null || $v === '') ? $dash : $v; // Modern semantic colour palette — drives every status pill on the page. $statusPalette = [ 'pending' => ['fg' => '#92400e', 'bg' => '#fef3c7', 'dot' => '#f59e0b'], 'confirmed' => ['fg' => '#065f46', 'bg' => '#d1fae5', 'dot' => '#10b981'], 'completed' => ['fg' => '#1e3a8a', 'bg' => '#dbeafe', 'dot' => '#3b82f6'], 'cancelled' => ['fg' => '#991b1b', 'bg' => '#fee2e2', 'dot' => '#ef4444'], 'no_show' => ['fg' => '#374151', 'bg' => '#e5e7eb', 'dot' => '#6b7280'], ]; $attendancePalette = [ 'coming' => ['fg' => '#1d4ed8', 'bg' => '#dbeafe', 'dot' => '#3b82f6'], 'attended' => ['fg' => '#065f46', 'bg' => '#d1fae5', 'dot' => '#10b981'], 'not_attended' => ['fg' => '#991b1b', 'bg' => '#fee2e2', 'dot' => '#ef4444'], 'cancelled' => ['fg' => '#475569', 'bg' => '#e2e8f0', 'dot' => '#64748b'], // Legacy backstop — kept so a row that escaped the backfill // still renders a readable pill until it gets updated. 'unknown' => ['fg' => '#374151', 'bg' => '#e5e7eb', 'dot' => '#6b7280'], ]; $typePalette = [ 'investor' => ['fg' => '#3730a3', 'bg' => '#e0e7ff', 'dot' => '#6366f1'], 'startup' => ['fg' => '#0c4a6e', 'bg' => '#e0f2fe', 'dot' => '#0ea5e9'], ]; $statusC = $statusPalette[$meeting->status] ?? ['fg' => '#475569', 'bg' => '#f1f5f9', 'dot' => '#64748b']; $attendanceC = $attendancePalette[$meeting->attendance_status] ?? ['fg' => '#475569', 'bg' => '#f1f5f9', 'dot' => '#64748b']; $typeC = $typePalette[$meeting->type] ?? ['fg' => '#475569', 'bg' => '#f1f5f9', 'dot' => '#64748b']; // Owner link (investor or startup) — surfaced as a chip and a "linked // profile" row in the info card. $ownerRoute = null; // Locale-aware owner name: AR name in Arabic UI / EN name in English UI, // with a cross-locale fallback when one side is missing. (The investor's // legacy `name` column was dropped in the 2026-06-09 refactor — reading // `->name` rendered a bare dash. See Meeting::ownerDisplayName().) $ownerLabel = $meeting->ownerDisplayName(); if ($meeting->meetingable) { if ($meeting->type === 'investor') { $ownerRoute = route('admin.investors.show', $meeting->meetingable); } elseif ($meeting->type === 'startup') { $ownerRoute = route('admin.startups.show', $meeting->meetingable); } } // Headline for the breadcrumb + hero title + tag. Prefer the // locale-aware owner name; fall back to the Bookings customer snapshot, // then the bare "#id" so the page reads as a person, not a number. $headlineName = $ownerLabel ?: (trim((string) $meeting->customer_name) ?: ('#' . $meeting->id)); // Duration in minutes for the hero chip + info row. We prefer the // persisted `duration_minutes` (set by the webhook job from canonical // Graph times) and fall back to a live diff so historical rows that // pre-date the backfill still render a number. $durationMinutes = $meeting->duration_minutes; if ($durationMinutes === null && $meeting->start_time && $meeting->end_time) { // Carbon 3.x: diffInMinutes is signed (a->diff(b) == b - a). Go // start→end so positive durations come out positive. $durationMinutes = (int) max(0, round($meeting->start_time->diffInMinutes($meeting->end_time))); } @endphp @section('title', $headlineName . ' — ' . __('admin.brand.name')) @section('page_title', __('admin.meetings_admin.show_title')) @section('content') {{-- ────────────────────────── BREADCRUMBS + BACK ───────────────────── --}} <div class="md-topbar"> <nav class="md-crumbs" aria-label="{{ __('admin.meetings_admin.show_title') }}"> <a href="{{ route('admin.dashboard') }}" class="md-crumbs__item">{{ __('admin.nav.dashboard') }}</a> <span class="md-crumbs__sep" aria-hidden="true">›</span> <a href="{{ route('admin.meetings.index') }}" class="md-crumbs__item">{{ __('admin.nav.meetings') }}</a> <span class="md-crumbs__sep" aria-hidden="true">›</span> <span class="md-crumbs__item md-crumbs__item--current" aria-current="page"> {{ $headlineName }} </span> </nav> <a href="{{ route('admin.meetings.index') }}" class="md-back" title="{{ __('admin.meetings_admin.back_to_list') }}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <line x1="19" y1="12" x2="5" y2="12"/> <polyline points="12 19 5 12 12 5"/> </svg> <span>{{ __('admin.meetings_admin.back') }}</span> </a> </div> @include('admin.partials.flash') {{-- ────────────────────────── HERO ─────────────────────────────────── --}} <section class="md-hero"> {{-- Date tile — visual anchor on the left. --}} <div class="md-hero__date" aria-hidden="true"> <span class="md-hero__date-mon">{{ $meeting->localStart?->format('M') ?: '—' }}</span> <span class="md-hero__date-day">{{ $meeting->localStart?->format('d') ?: '—' }}</span> <span class="md-hero__date-year">{{ $meeting->localStart?->format('Y') ?: '' }}</span> </div> <div class="md-hero__body"> <h1 class="md-hero__title"> {{ $headlineName }} </h1> @if ($meeting->title) <p class="md-hero__sub">{{ $meeting->title }}</p> @endif <div class="md-hero__meta"> <span class="md-pill" style="--fg:{{ $typeC['fg'] }};--bg:{{ $typeC['bg'] }};--dot:{{ $typeC['dot'] }}"> <span class="md-pill__dot"></span>{{ __('admin.meetings_admin.types.' . $meeting->type) }} </span> <span class="md-pill" style="--fg:{{ $statusC['fg'] }};--bg:{{ $statusC['bg'] }};--dot:{{ $statusC['dot'] }}"> <span class="md-pill__dot"></span>{{ __('admin.meetings_admin.statuses.' . $meeting->status) }} </span> <span class="md-pill" style="--fg:{{ $attendanceC['fg'] }};--bg:{{ $attendanceC['bg'] }};--dot:{{ $attendanceC['dot'] }}"> <span class="md-pill__dot"></span>{{ __('admin.meetings_admin.attendances.' . $meeting->attendance_status) }} </span> @if ($meeting->start_time) <span class="md-meta-item"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> </svg> <strong>{{ $meeting->localStart?->format('H:i') }}</strong> @if ($meeting->end_time)<span class="md-meta-item__hint">– {{ $meeting->localEnd?->format('H:i') }}</span>@endif </span> @endif @if ($durationMinutes !== null) <span class="md-meta-item"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/> </svg> {{ __('admin.meetings_admin.duration_minutes', ['count' => $durationMinutes]) }} </span> @endif @if ($ownerRoute) <a href="{{ $ownerRoute }}" class="md-meta-item md-meta-item--link"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> </svg> <strong>{{ $ownerLabel ?: $dash }}</strong> </a> @endif </div> </div> <div class="md-hero__cta" x-data="meetingResync({ endpoint: @js(route('admin.meetings.resync', $meeting)), csrf: @js(csrf_token()), i18n: @js([ 'idle' => __('admin.meetings_admin.resync_idle'), 'busy' => __('admin.meetings_admin.resync_busy'), 'done' => __('admin.meetings_admin.resync_done'), 'failed' => __('admin.meetings_admin.resync_failed'), ]), })"> @if ($meeting->meeting_url) <a href="{{ $meeting->meeting_url }}" target="_blank" rel="noopener" class="md-btn md-btn--primary"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <polygon points="23 7 16 12 23 17 23 7"/> <rect x="1" y="5" width="15" height="14" rx="2" ry="2"/> </svg> <span>{{ __('admin.meetings_admin.open_in_teams') }}</span> </a> @else <span class="md-hero__no-link">{{ __('admin.meetings_admin.no_teams_link') }}</span> @endif {{-- Resync button — pulls latest state from Microsoft Graph and re-runs the upsert pipeline. Useful when webhooks aren't reaching this environment (local dev) or have lapsed silently. --}} <button type="button" class="md-btn md-btn--secondary" @click="run()" :disabled="state === 'busy'" :title="i18n.idle"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" :style="state === 'busy' ? 'animation: md-resync-spin 1s linear infinite' : ''"> <polyline points="23 4 23 10 17 10"/> <polyline points="1 20 1 14 7 14"/> <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/> <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/> </svg> <span x-text="label"></span> </button> </div> </section> {{-- ────────────────────────── 2-COL GRID ───────────────────────────── --}} <div class="md-grid" x-data="meetingEditor({ initial: @js([ 'attendance_status' => $meeting->attendance_status, 'meeting_notes' => $meeting->meeting_notes, ]), attendances: @js(\App\Models\Meeting::attendanceOptions()), endpoint: @js(route('admin.meetings.update', $meeting)), csrf: @js(csrf_token()), i18n: @js([ 'saving' => __('admin.meetings_admin.saving_in_progress'), 'save' => __('admin.meetings_admin.save_changes'), 'saved' => __('admin.meetings_admin.notes_saved'), 'failed' => __('admin.meetings_admin.save_failed'), 'unsaved' => __('admin.meetings_admin.unsaved_warning'), 'copy' => __('admin.meetings_admin.copy_booking_id'), 'copied' => __('admin.meetings_admin.copied'), ]), attendanceLabels: @js(collect(\App\Models\Meeting::attendanceOptions())->mapWithKeys(fn($a) => [$a => __('admin.meetings_admin.attendances.' . $a)])), attendancePalette:@js($attendancePalette), })" x-cloak> {{-- ════════════════════════ LEFT COLUMN ═══════════════════════ --}} <div class="md-col"> <section class="md-card"> <header class="md-card__head"> <h2 class="md-card__title">{{ __('admin.meetings_admin.meeting_info') }}</h2> </header> <div class="md-card__body"> <dl class="md-info"> @if ($meeting->title) <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.title') }}</dt> <dd><strong>{{ $meeting->title }}</strong></dd> </div> @endif {{-- Description block removed per UX request: redundant with meeting_notes (admin-owned) and with the customer's signup data. --}} <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.type') }}</dt> <dd> <span class="md-pill md-pill--sm" style="--fg:{{ $typeC['fg'] }};--bg:{{ $typeC['bg'] }};--dot:{{ $typeC['dot'] }}"> <span class="md-pill__dot"></span>{{ __('admin.meetings_admin.types.' . $meeting->type) }} </span> </dd> </div> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.meeting_date') }}</dt> <dd>{{ $meeting->meeting_date?->format('Y-m-d') ?: $dash }}</dd> </div> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.start_time') }}</dt> <dd>{{ $meeting->localStart?->format('Y-m-d H:i') ?: $dash }}</dd> </div> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.end_time') }}</dt> <dd>{{ $meeting->localEnd?->format('Y-m-d H:i') ?: $dash }}</dd> </div> @if ($durationMinutes !== null) <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.duration') }}</dt> <dd>{{ __('admin.meetings_admin.duration_minutes', ['count' => $durationMinutes]) }}</dd> </div> @endif @if ($meeting->timezone) <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.timezone') }}</dt> <dd><code class="md-mono">{{ $meeting->timezone }}</code></dd> </div> @endif @if ($meeting->location) <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.location') }}</dt> <dd>{{ $meeting->location }}</dd> </div> @endif @if ($meeting->meeting_url) <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.meeting_url') }}</dt> <dd> <a href="{{ $meeting->meeting_url }}" target="_blank" rel="noopener" class="md-link"> {{ \Illuminate\Support\Str::limit($meeting->meeting_url, 80) }} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </a> </dd> </div> @endif {{-- Staff assignment row. The PRIMARY assignee is whoever the auto-pick (or admin override) chose; the BLOCKED list (below) is everyone whose Microsoft calendar now holds this booking — they're operationally unavailable at this slot even though the meeting is organisationally owned by the primary. --}} @if ($meeting->staff_name || $meeting->staff_email || $meeting->staff_id) <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.staff') }}</dt> <dd> @if ($meeting->staff_name) <strong>{{ $meeting->staff_name }}</strong> @else <code class="md-mono">{{ $meeting->staff_id }}</code> @endif @if ($meeting->staff_email)  ·  <a href="mailto:{{ $meeting->staff_email }}">{{ $meeting->staff_email }}</a> @endif </dd> </div> @endif {{-- Blocked staff list — snapshotted at booking time from StaffDirectory. Shows EVERY staff whose Microsoft calendar got the appointment, so an admin can see at a glance "this booking blocked X, Y, and Z." Only renders when there are 2+ entries (a single-entry list adds no info beyond the primary row above). --}} @php $blocked = is_array($meeting->blocked_staff) ? $meeting->blocked_staff : []; @endphp @if (count($blocked) > 1) <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.blocked_staff') }}</dt> <dd> <ul class="md-blocked-staff"> @foreach ($blocked as $s) @php $isPrimary = ($s['id'] ?? null) === $meeting->staff_id; $sname = trim((string) ($s['name'] ?? '')); $semail = trim((string) ($s['email'] ?? '')); @endphp <li> @if ($sname !== '') <strong>{{ $sname }}</strong> @else <code class="md-mono">{{ $s['id'] ?? '—' }}</code> @endif @if ($isPrimary) <span class="md-blocked-staff__primary">{{ __('admin.meetings_admin.fields.primary') ?: 'primary' }}</span> @endif @if ($semail !== '')  ·  <a href="mailto:{{ $semail }}">{{ $semail }}</a> @endif </li> @endforeach </ul> </dd> </div> @endif <div class="md-info__row md-info__row--full"> <dt>{{ __('admin.meetings_admin.fields.booking_id') }}</dt> <dd class="md-info__booking"> <code class="md-mono md-mono--bid">{{ $meeting->booking_id }}</code> <button type="button" class="md-copy" @click="copyBookingId(@js($meeting->booking_id))" :title="copyState === 'copied' ? i18n.copied : i18n.copy"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/> </svg> <span x-text="copyState === 'copied' ? i18n.copied : i18n.copy"></span> </button> </dd> </div> @if ($meeting->source) <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.source') }}</dt> <dd><code class="md-mono">{{ $meeting->source }}</code></dd> </div> @endif <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.created_at') }}</dt> <dd>{{ $meeting->created_at?->format('Y-m-d H:i') ?: $dash }}</dd> </div> </dl> </div> </section> {{-- Customer card --}} <section class="md-card"> <header class="md-card__head"> <h2 class="md-card__title">{{ __('admin.meetings_admin.sections.customer') }}</h2> </header> <div class="md-card__body"> <dl class="md-info"> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.customer_name') }}</dt> <dd><strong>{{ $text($meeting->customer_name) }}</strong></dd> </div> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.customer_email') }}</dt> <dd> @if ($meeting->customer_email) <a href="mailto:{{ $meeting->customer_email }}" class="md-link">{{ $meeting->customer_email }}</a> @else {{ $dash }} @endif </dd> </div> <div class="md-info__row"> <dt>{{ __('admin.meetings_admin.fields.customer_phone') }}</dt> <dd>{{ $text($meeting->customer_phone) }}</dd> </div> </dl> </div> </section> </div> {{-- ════════════════════════ RIGHT COLUMN ══════════════════════ --}} <aside class="md-col md-col--side"> <section class="md-card md-card--sticky"> <header class="md-card__head md-card__head--split"> <h2 class="md-card__title">{{ __('admin.meetings_admin.admin_controls') }}</h2> <span class="md-savestate" :data-state="state" x-text="statusText"></span> </header> <div class="md-card__body"> {{-- Status select removed per UX request: status is system-managed (webhook + cancel listener) and shouldn't be admin-editable. The hero pill at the top still shows the current status so admins can tell when a meeting was cancelled. --}} {{-- Attendance --}} {{-- Options rendered server-side from a Blade @foreach (NOT `<template x-for>`). Some browsers leak the <template> contents into the parent <select>'s visible options list, which produced the dropdown showing each attendance value twice. Static Blade rendering eliminates the risk entirely. --}} <div class="md-field"> <label class="md-field__label" for="m_att">{{ __('admin.meetings_admin.fields.attendance_status') }}</label> <div class="md-field__row"> <select id="m_att" class="md-select" x-model="form.attendance_status" :disabled="saving"> @foreach (\App\Models\Meeting::attendanceOptions() as $a) <option value="{{ $a }}">{{ __('admin.meetings_admin.attendances.' . $a) }}</option> @endforeach </select> <span class="md-pill md-pill--sm md-pill--preview" :style="`--fg:${attendancePalette[form.attendance_status]?.fg};--bg:${attendancePalette[form.attendance_status]?.bg};--dot:${attendancePalette[form.attendance_status]?.dot}`"> <span class="md-pill__dot"></span><span x-text="attendanceLabels[form.attendance_status]"></span> </span> </div> </div> {{-- Notes --}} <div class="md-field"> <label class="md-field__label" for="m_notes">{{ __('admin.meetings_admin.fields.meeting_notes') }}</label> <textarea id="m_notes" class="md-textarea" rows="6" x-model="form.meeting_notes" :placeholder="'{{ __('admin.meetings_admin.notes_placeholder') }}'" :disabled="saving" x-init="autosize($el); $watch('form.meeting_notes', () => autosize($el))"></textarea> <span class="md-hint">{{ __('admin.meetings_admin.notes_hint') }}</span> </div> </div> <div class="md-card__foot"> <span class="md-dirty" x-show="dirty" x-transition.opacity> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/> </svg> {{ __('admin.meetings_admin.unsaved_warning') }} </span> <button type="button" class="md-btn md-btn--primary md-btn--full" @click="save()" :disabled="saving || !dirty"> <template x-if="!saving"> <span class="md-btn__inner"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <polyline points="20 6 9 17 4 12"/> </svg> {{ __('admin.meetings_admin.save_changes') }} </span> </template> <template x-if="saving"> <span class="md-btn__inner"> <span class="md-spinner" aria-hidden="true"></span> {{ __('admin.meetings_admin.saving_in_progress') }} </span> </template> </button> </div> </section> </aside> </div> @endsection @push('styles') <style> /* ─── Layout shell ─────────────────────────────────────────────── */ .md-topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 14px; } /* ─── Breadcrumbs ──────────────────────────────────────────────── */ .md-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 13px; color: #64748b; } .md-crumbs__item { display: inline-flex; align-items: center; color: #64748b; text-decoration: none; font-weight: 600; padding: 4px 8px; border-radius: 6px; transition: background-color .15s, color .15s; } a.md-crumbs__item:hover { background: #f1f5f9; color: #0f172a; } .md-crumbs__item--current { color: #0f172a; cursor: default; } .md-crumbs__sep { color: #cbd5e1; font-size: 11px; user-select: none; } [dir="rtl"] .md-crumbs__sep { transform: scaleX(-1); } /* ─── Back button ──────────────────────────────────────────────── */ .md-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: #fff; color: #475569; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; transition: all .15s; } .md-back:hover { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; } [dir="rtl"] .md-back svg { transform: scaleX(-1); } /* ─── HERO ─────────────────────────────────────────────────────── */ .md-hero { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 22px; align-items: center; padding: 22px 24px; background: #fff; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 4px 12px -8px rgba(15,23,42,.08); margin-bottom: 18px; } .md-hero__date { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 78px; padding: 12px 16px; background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px; line-height: 1; } .md-hero__date-mon { font-size: 11px; font-weight: 700; color: #4f46e5; text-transform: uppercase; letter-spacing: .08em; } .md-hero__date-day { font-size: 30px; font-weight: 800; color: #0f172a; margin: 4px 0 2px; } .md-hero__date-year { font-size: 11px; font-weight: 600; color: #64748b; } .md-hero__body { display: grid; gap: 8px; min-width: 0; } .md-hero__title { margin: 0; font-size: 22px; font-weight: 800; color: #0f172a; line-height: 1.2; letter-spacing: -.01em; word-break: break-word; } .md-hero__sub { margin: 0; font-size: 13.5px; color: #64748b; } .md-hero__meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 4px; } .md-hero__cta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .md-hero__no-link { font-size: 12.5px; color: #94a3b8; font-style: italic; } /* ─── Modern pill badges ───────────────────────────────────────── */ .md-pill { --fg: #475569; --bg: #f1f5f9; --dot: #64748b; display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; background: var(--bg); color: var(--fg); border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 1.5; text-decoration: none; } .md-pill--sm { padding: 3px 9px; font-size: 11.5px; } .md-pill__dot { display: inline-block; width: 6px; height: 6px; border-radius: 999px; background: var(--dot); flex-shrink: 0; } .md-pill--preview { align-self: center; } /* ─── Inline hero meta items (time / duration / link) ──────────── */ .md-meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #475569; } .md-meta-item svg { color: #94a3b8; } .md-meta-item strong { color: #0f172a; font-weight: 700; } .md-meta-item__hint { color: #94a3b8; } .md-meta-item--link { text-decoration: none; padding: 4px 10px; border-radius: 8px; background: #eef2ff; color: #4338ca; font-weight: 600; transition: background-color .15s; } .md-meta-item--link:hover { background: #e0e7ff; } .md-meta-item--link svg { color: #6366f1; } /* ─── 2-column grid ────────────────────────────────────────────── */ .md-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 360px); gap: 18px; align-items: start; } .md-col { display: grid; gap: 18px; min-width: 0; } /* ─── Card ─────────────────────────────────────────────────────── */ .md-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 14px; box-shadow: 0 1px 2px rgba(15,23,42,.03); overflow: hidden; } .md-card--sticky { position: sticky; top: 16px; } .md-card__head { padding: 14px 20px; border-bottom: 1px solid #f1f5f9; } .md-card__head--split { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .md-card__title { margin: 0; font-size: 14px; font-weight: 700; color: #0f172a; letter-spacing: -.005em; } .md-card__body { padding: 18px 20px; } .md-card__foot { padding: 14px 20px; background: #fafbfc; border-top: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 12px; } /* ─── Info list (left card) ────────────────────────────────────── */ .md-info { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; margin: 0; } .md-info__row { display: grid; gap: 4px; min-width: 0; } .md-info__row--full { grid-column: 1 / -1; } /* Blocked-staff list — one row per assignee, primary tagged. */ .md-blocked-staff { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .md-blocked-staff li { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13.5px; } .md-blocked-staff__primary { display: inline-flex; align-items: center; padding: 1px 8px; background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; border-radius: 999px; font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; } .md-info dt { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .06em; margin: 0; } .md-info dd { margin: 0; font-size: 14px; color: #0f172a; line-height: 1.5; word-break: break-word; } .md-info dd strong { font-weight: 700; } .md-info__multiline { white-space: pre-line; } .md-info__booking { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } .md-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: #f1f5f9; color: #334155; padding: 3px 8px; border-radius: 6px; word-break: break-all; } .md-mono--bid { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; } /* ─── Link ─────────────────────────────────────────────────────── */ .md-link { display: inline-flex; align-items: center; gap: 6px; color: #4f46e5; text-decoration: none; font-weight: 600; word-break: break-all; } .md-link:hover { text-decoration: underline; } .md-link svg { color: #94a3b8; flex-shrink: 0; } /* ─── Copy button ──────────────────────────────────────────────── */ .md-copy { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; background: #fff; color: #475569; border: 1px solid #e2e8f0; border-radius: 6px; font: inherit; font-size: 11.5px; font-weight: 600; cursor: pointer; transition: all .15s; } .md-copy:hover { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; } .md-copy:active { transform: translateY(1px); } /* ─── Form fields (right card) ─────────────────────────────────── */ .md-field { display: grid; gap: 6px; margin-bottom: 16px; } .md-field:last-child { margin-bottom: 0; } .md-field__label { font-size: 11.5px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: .04em; } .md-field__row { display: flex; align-items: center; gap: 8px; } .md-select, .md-textarea { width: 100%; padding: 8px 12px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 8px; font: inherit; font-size: 14px; transition: border-color .15s, box-shadow .15s; } .md-textarea { min-height: 110px; resize: vertical; line-height: 1.6; } .md-select:focus, .md-textarea:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,.15); } .md-select:disabled, .md-textarea:disabled { background: #f8fafc; cursor: not-allowed; opacity: .65; } .md-hint { font-size: 11px; color: #94a3b8; line-height: 1.5; } /* ─── Save state badge in card head ────────────────────────────── */ .md-savestate { font-size: 11.5px; font-weight: 700; color: #64748b; } .md-savestate[data-state="dirty"] { color: #b45309; } .md-savestate[data-state="saving"] { color: #4338ca; } .md-savestate[data-state="saved"] { color: #047857; } .md-savestate[data-state="error"] { color: #b91c1c; } /* ─── Unsaved indicator ────────────────────────────────────────── */ .md-dirty { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #b45309; } .md-dirty svg { color: #d97706; } /* ─── Buttons ──────────────────────────────────────────────────── */ .md-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 18px; min-height: 38px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 8px; font: inherit; font-size: 13.5px; font-weight: 700; text-decoration: none; cursor: pointer; transition: all .15s; } .md-btn:hover:not(:disabled) { background: #f8fafc; } .md-btn--primary { background: #4f46e5; color: #fff; border-color: #4f46e5; box-shadow: 0 1px 2px rgba(79,70,229,.18), 0 4px 8px -2px rgba(79,70,229,.22); } .md-btn--primary:hover:not(:disabled) { background: #4338ca; border-color: #4338ca; } .md-btn--primary:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; } /* Secondary CTA — neutral surface with subtle accent on hover. Used for the Microsoft Resync button so it doesn't compete with the primary "Open in Teams" CTA for attention. */ .md-btn--secondary { background: #fff; color: #4338ca; border-color: #c7d2fe; } .md-btn--secondary:hover:not(:disabled) { background: #eef2ff; border-color: #818cf8; color: #312e81; } .md-btn--secondary:disabled { opacity: .6; cursor: not-allowed; } .md-btn--full { width: 100%; } .md-btn__inner { display: inline-flex; align-items: center; gap: 6px; } /* Spin animation specifically for the resync icon (separate keyframe so it can run on any inline SVG via style binding). */ @keyframes md-resync-spin { to { transform: rotate(360deg); } } /* ─── Spinner ──────────────────────────────────────────────────── */ .md-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 999px; animation: md-spin 700ms linear infinite; } @keyframes md-spin { to { transform: rotate(360deg); } } /* ─── Responsive ───────────────────────────────────────────────── */ @media (max-width: 960px) { .md-grid { grid-template-columns: 1fr; } .md-card--sticky { position: static; } .md-info { grid-template-columns: 1fr; gap: 14px; } .md-hero { grid-template-columns: auto minmax(0,1fr); gap: 16px; padding: 18px 20px; } .md-hero__cta { grid-column: 1 / -1; } .md-hero__cta .md-btn { width: 100%; } } @media (max-width: 640px) { .md-topbar { gap: 8px; } .md-back span { display: none; } /* icon-only on phones */ .md-back { padding: 7px 9px; } .md-hero { grid-template-columns: auto 1fr; padding: 16px; gap: 14px; } .md-hero__date { min-width: 64px; padding: 10px 12px; } .md-hero__date-day { font-size: 26px; } .md-hero__title { font-size: 18px; } .md-card__body { padding: 16px; } .md-card__head, .md-card__foot { padding: 12px 16px; } .md-field__row { flex-direction: column; align-items: stretch; } .md-field__row .md-pill--preview { align-self: flex-start; } /* Sticky save bar — admin doesn't need to scroll to the end on a phone to save. Lives in the card foot but pinned to viewport. */ .md-card__foot { position: sticky; bottom: 0; z-index: 20; backdrop-filter: blur(8px); background: rgba(250, 251, 252, .94); } } [x-cloak] { display: none !important; } </style> @endpush @push('scripts') {{-- Alpine.js is already loaded by admin.layouts.app — loading it again would double-bind every directive on this page. --}} <script> /** * Meeting editor — explicit Save model. * * No autosave. Edits are buffered in `form` and compared against the * baseline (`initial`) to compute `dirty`. The button stays disabled * until something differs from the server-side snapshot. * * The Alpine `meetingEditor` instance also handles: * - copy-to-clipboard for the Microsoft booking_id (with 1.5s "Copied" * pulse) * - dynamic palette on the live preview pill next to each select * - textarea autosize for the notes field * - "Unsaved changes" beforeunload guard * * On save: * - sends only the fields that actually changed (clean audit log) * - rebases `initial` to the new values so dirty becomes false again */ document.addEventListener('alpine:init', () => { /** * meetingResync — force-pull canonical state from Microsoft Graph. * Reloads the page on success so every field on the show view reflects * the freshly-synced row (status badge, dates, staff, duration). On * failure we surface the error inline without losing the page. */ Alpine.data('meetingResync', (cfg) => ({ endpoint: cfg.endpoint, csrf: cfg.csrf, i18n: cfg.i18n, state: 'idle', // 'idle' | 'busy' | 'done' | 'error' get label() { return this.i18n[this.state] || this.i18n.idle; }, async run() { if (this.state === 'busy') return; this.state = 'busy'; try { const res = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': this.csrf, 'X-Requested-With': 'XMLHttpRequest', }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const json = await res.json().catch(() => ({})); if (json.ok === false) throw new Error(json.error || 'resync_failed'); this.state = 'done'; // Reload so the whole view reflects the canonical state // — easier than patching every cell individually. setTimeout(() => window.location.reload(), 400); } catch (e) { console.error('meeting resync failed', e); this.state = 'error'; setTimeout(() => { if (this.state === 'error') this.state = 'idle'; }, 2500); } }, })); Alpine.data('meetingEditor', (cfg) => ({ initial: { ...cfg.initial }, form: { ...cfg.initial }, // status fields removed — admins edit attendance + notes only. // The meeting's lifecycle status is system-managed (webhook + // cancel-on-label listener). attendances: cfg.attendances, attendanceLabels: cfg.attendanceLabels, attendancePalette: cfg.attendancePalette, endpoint: cfg.endpoint, csrf: cfg.csrf, i18n: cfg.i18n, saving: false, state: 'idle', // 'idle' | 'dirty' | 'saving' | 'saved' | 'error' statusText: '', copyState: 'idle', // 'idle' | 'copied' _statusTimer: null, _copyTimer: null, init() { this.$watch('form.attendance_status', () => this.markDirty()); this.$watch('form.meeting_notes', () => this.markDirty()); // Warn before leaving the page if there are unsaved changes. window.addEventListener('beforeunload', (e) => { if (!this.dirty) return; e.preventDefault(); e.returnValue = ''; }); }, get dirty() { return this.form.attendance_status !== this.initial.attendance_status || (this.form.meeting_notes ?? '') !== (this.initial.meeting_notes ?? ''); }, markDirty() { if (this.dirty) { this.state = 'dirty'; this.statusText = this.i18n.unsaved; } else { this.state = 'idle'; this.statusText = ''; } }, async save() { if (!this.dirty || this.saving) return; this.saving = true; this.state = 'saving'; this.statusText = this.i18n.saving; try { // Send only the fields that actually changed. const payload = {}; if (this.form.attendance_status !== this.initial.attendance_status) payload.attendance_status = this.form.attendance_status; if ((this.form.meeting_notes ?? '') !== (this.initial.meeting_notes ?? '')) payload.meeting_notes = this.form.meeting_notes; const res = await fetch(this.endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': this.csrf, 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error('HTTP ' + res.status); const json = await res.json().catch(() => ({})); if (json.ok === false) throw new Error(json.error || 'update_failed'); // Rebase baseline so dirty flips back to false. this.initial = { ...this.form }; this.state = 'saved'; this.statusText = this.i18n.saved; clearTimeout(this._statusTimer); this._statusTimer = setTimeout(() => { if (this.state === 'saved') { this.state = 'idle'; this.statusText = ''; } }, 1800); } catch (e) { console.error('meeting save failed', e); this.state = 'error'; this.statusText = this.i18n.failed; } finally { this.saving = false; } }, async copyBookingId(id) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(id); } else { // Fallback for older browsers / non-HTTPS contexts. const ta = document.createElement('textarea'); ta.value = id; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } this.copyState = 'copied'; clearTimeout(this._copyTimer); this._copyTimer = setTimeout(() => { this.copyState = 'idle'; }, 1500); } catch (e) { console.error('copy failed', e); } }, autosize(el) { if (!el) return; el.style.height = 'auto'; el.style.height = (el.scrollHeight + 2) + 'px'; }, })); }); </script> @endpush