Files
hotline-planner/monolith/hlp quasar initial layout split/hlp v2 light 800 agents 2026-02-16 09-31.html
2026-02-23 14:02:44 +01:00

1290 lines
61 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--left-col-width: 240px;
--cell-width: 100px;
/* Dynamic offsets controlled by Vue */
--h-eod: 42px;
--h-status: 34px;
--h-dates: 52px;
--primary-soft: #f8fafc;
--border-color: #e2e8f0;
/* Crosshair Colors */
--highlight-bg: rgba(99, 102, 241, 0.12);
--highlight-border: rgba(99, 102, 241, 0.5);
}
/* MOBILE OPTIMIZATION */
@media (max-width: 599px) {
:root {
--left-col-width: 160px;
}
.left-col {
padding: 0 8px !important;
}
.agent-role {
display: none;
}
}
body, html {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: #f8fafc;
color: #334155;
}
.grid-viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.grid-viewport::-webkit-scrollbar-track {
background: #f1f5f9;
}
.grid-viewport::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 20px;
border: 2px solid #f1f5f9;
}
/* Important: This must be the scroll container for QVirtualScroll */
.grid-viewport {
height: calc(100vh - 64px) !important;
overflow: auto !important;
background: white;
position: relative;
display: block;
border-top: 1px solid var(--border-color);
}
.planner-content {
display: inline-block;
min-width: fit-content;
vertical-align: top;
}
.planner-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f1f5f9;
}
.left-col {
width: var(--left-col-width);
min-width: var(--left-col-width);
position: sticky;
left: 0;
z-index: 20;
background: white;
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
}
/* Sticky Header Classes */
.row-eod {
position: sticky;
top: 0;
z-index: 40;
height: var(--h-eod);
background: #fffafb;
}
.row-status {
position: sticky;
top: var(--h-eod);
z-index: 39;
height: var(--h-status);
background: white;
}
.row-dates {
position: sticky;
top: calc(var(--h-eod) + var(--h-status));
z-index: 38;
height: var(--h-dates);
background: #fdfdfd;
}
.row-eod .left-col { z-index: 60; background: #fffafb; color: #db2777; border-bottom: 1px solid #fce7f3; }
.row-status .left-col { z-index: 59; background: white; color: #64748b; }
.row-dates .left-col { z-index: 58; background: #fdfdfd; padding-right: 12px; border-bottom: 1px solid var(--border-color); }
.cells-area { display: flex; flex-direction: row; }
.cell {
width: var(--cell-width);
min-width: var(--cell-width);
height: 58px;
border-right: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.1s ease;
}
.cell:hover:not(.cursor-not-allowed) { background-color: #f8fafc; }
.cell-compact { height: 42px; }
.shift-badge {
font-size: 9px;
font-weight: 800;
padding: 2px 4px;
border-radius: 4px;
width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
border: 1px solid rgba(0,0,0,0.05);
}
/* --- GROUP HEADER STYLES --- */
/* Level 1 Header (e.g., Hub) */
.group-header-l1 {
background: #eef2ff; /* Indigo-50 */
font-size: 12px;
font-weight: 900;
color: #3730a3; /* Indigo-800 */
letter-spacing: 0.05em;
height: 36px;
border-top: 1px solid #c7d2fe;
border-bottom: 1px solid #e0e7ff;
}
.group-header-l1 .left-col {
background: #eef2ff;
color: #3730a3;
}
/* Level 2 Header (e.g., Division) */
.group-header-l2 {
background: #f8fafc; /* Slate-50 */
font-size: 11px;
font-weight: 800;
color: #64748b; /* Slate-500 */
letter-spacing: 0.025em;
height: 32px;
border-top: 1px solid #e2e8f0;
}
.group-header-l2 .left-col {
background: #f8fafc;
color: #64748b;
padding-left: 24px; /* Indent */
}
.locked-overlay {
position: absolute;
inset: 0;
background: rgba(241, 245, 249, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.grid-search-container {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.bg-weekend { background-color: #f1f5f9 !important; }
.text-sat { color: #166534 !important; }
.text-sun { color: #991b1b !important; }
.avatar-stack-item {
margin-left: -8px;
border: 2px solid white;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.avatar-stack-img:hover {
transform: translateY(-2px);
z-index: 10;
border-color: #cbd5e1;
}
.avatar-stack-count-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #f1f5f9;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 5;
border: 2px solid white;
}
.profile-card { min-width: 260px; padding: 16px; }
.q-field--outlined.q-field--rounded .q-field__control { border-radius: 12px; }
.q-btn--rounded { border-radius: 10px; }
.dense-date-card { width: 290px; }
.dense-date-card .q-date { width: 100%; min-height: unset; }
.shift-tooltip {
padding: 10px 12px;
max-width: 220px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.filter-section-title {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.1em;
color: #94a3b8;
margin-top: 24px;
margin-bottom: 12px;
text-transform: uppercase;
}
.filter-btn-active {
background-color: #e0e7ff !important; /* bg-indigo-1 */
border: 1px solid #c7d2fe !important; /* border-indigo-2 */
}
.filter-status-dot {
width: 8px;
height: 8px;
background-color: #f59e0b; /* amber-500 */
border-radius: 50%;
position: absolute;
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px #e0e7ff;
z-index: 10;
}
/* --- PERSISTENT READING HELPER STYLES (Toggle Eye) --- */
.bg-reading-mode {
background-color: #fff9c4 !important;
}
.bg-reading-mode-intersection {
background-color: #fff176 !important;
}
.reading-active-header {
background-color: #fef08a !important;
border-bottom: 2px solid #eab308;
}
/* MODIFIED: Removed borders to match user request */
.row-highlighted .left-col {
background-color: #fff9c4 !important;
}
.agent-row-hover-trigger .agent-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.agent-row-hover-trigger:hover .agent-highlight-btn,
.agent-highlight-btn.is-active {
opacity: 1;
}
.date-header-hover-trigger .header-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.date-header-hover-trigger:hover .header-highlight-btn,
.header-highlight-btn.is-active {
opacity: 1;
}
/* --- CROSSHAIR STYLES (Dynamic Hover) --- */
.col-hovered {
background-color: var(--highlight-bg) !important;
box-shadow: inset 1px 0 0 var(--highlight-border), inset -1px 0 0 var(--highlight-border);
z-index: 1;
}
/* Performance Optimization: Use :hover for Row Highlight instead of JS */
/* MODIFIED: Now scoped to .crosshair-enabled class */
.planner-row-item.crosshair-enabled:hover {
background-color: var(--highlight-bg);
box-shadow: inset 0 1px 0 var(--highlight-border), inset 0 -1px 0 var(--highlight-border);
}
.planner-row-item.crosshair-enabled:hover .left-col {
background-color: #e0e7ff !important;
border-top: 1px solid var(--highlight-border);
border-bottom: 1px solid var(--highlight-border);
color: #3730a3;
}
</style>
</head>
<body>
<div id="q-app">
<q-layout view="hHh Lpr fFf">
<q-header class="bg-white text-grey-9 border-b" style="border-bottom: 1px solid #e2e8f0">
<q-toolbar class="q-py-sm">
<q-btn flat round dense icon="menu" color="grey-7" @click="leftDrawer = !leftDrawer"></q-btn>
<div class="row items-center q-ml-md">
<div class="bg-blue-grey-1 q-pa-xs rounded-borders q-mr-sm">
<q-icon name="event_note" color="blue-grey-8" size="24px"></q-icon>
</div>
<q-toolbar-title class="text-weight-bold text-subtitle1 text-indigo-10 ellipsis" style="max-width: 150px;">Hotline Planner</q-toolbar-title>
</div>
<q-space></q-space>
<div class="row items-center q-gutter-sm">
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
<q-btn v-if="highlightedRowId || highlightedDateStr"
outline rounded dense
color="amber-9"
icon="highlight_off"
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
class="q-mr-xs bg-amber-1"
@click="clearHighlights">
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
</q-btn>
</transition>
<div class="row items-center gt-sm q-mr-sm">
<div class="row items-center no-wrap">
<q-avatar v-for="user in visibleOnlineUsers" :key="user.id" size="32px" class="avatar-stack-item avatar-stack-img">
<img :src="user.img">
<q-tooltip anchor="bottom middle" self="top middle" :offset="[0, 10]" class="bg-white text-grey-9 shadow-10 q-pa-none rounded-borders" style="border: 1px solid #e2e8f0">
<div class="profile-card row no-wrap items-center">
<q-avatar size="54px" class="q-mr-md shadow-1"><img :src="user.img"></q-avatar>
<div class="column">
<div class="text-weight-bold text-body2">{{ user.name }}</div>
<div class="text-caption text-grey-6">{{ user.role }}</div>
<div class="text-caption text-blue-6 text-weight-medium">Online Now</div>
</div>
</div>
</q-tooltip>
</q-avatar>
<div v-if="remainingOnlineCount > 0" class="avatar-stack-item avatar-stack-count-circle cursor-pointer">
+{{ remainingOnlineCount }}
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
<q-list dense style="min-width: 240px">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Active Collaborators</q-item-label>
<q-separator></q-separator>
<q-scroll-area style="height: 250px;">
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</div>
</div>
</div>
<div class="lt-md">
<q-btn round flat dense color="grey-6" icon="group">
<q-badge color="red" floating rounded>{{ onlineUsers.length }}</q-badge>
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
<q-list dense style="min-width: 240px">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Online Now</q-item-label>
<q-separator></q-separator>
<q-scroll-area style="height: 250px;">
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</q-btn>
</div>
<q-separator vertical inset class="gt-xs q-mx-sm"></q-separator>
<q-btn flat round dense icon="notifications" color="grey-6" class="gt-xs"></q-btn>
<q-avatar size="32px" class="cursor-pointer q-ml-xs"><img src="https://i.pravatar.cc/150?u=me"></q-avatar>
</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawer" side="left" bordered :width="300" class="bg-white">
<q-scroll-area class="fit">
<div class="q-pa-lg">
<div class="text-h6 text-weight-bold q-mb-xs text-indigo-10">Workspace</div>
<div class="text-caption text-grey-6 q-mb-xl">Manage grid and picker preferences.</div>
<div class="column q-gutter-y-lg">
<div>
<div class="text-overline text-grey-6 q-mb-sm">Timeline Range</div>
<q-select v-model="viewScope" :options="[4, 8, 12]" outlined dense rounded emit-value map-options suffix=" Weeks" bg-color="white"></q-select>
</div>
<div>
<div class="text-overline text-grey-6 q-mb-sm">Date Picker Week Start</div>
<q-select v-model="pickerStartDay" :options="[{label: 'Sunday', value: 0}, {label: 'Monday', value: 1}]" outlined dense rounded emit-value map-options bg-color="white"></q-select>
</div>
<q-separator></q-separator>
<!-- NEW SIMULATION CONTROL -->
<div>
<div class="text-overline text-grey-6 q-mb-sm">Dev Tools</div>
<q-btn
outline
rounded
color="purple-7"
label="Simulate WSS Lock"
class="full-width"
icon="lock"
@click="simulateWssLock"
>
<q-tooltip>Simulate receiving a "Lock Cell" message from server</q-tooltip>
</q-btn>
</div>
<q-separator></q-separator>
<div>
<div class="text-overline text-grey-6 q-mb-sm">Grid Visibility</div>
<!-- Crosshair Toggle -->
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label class="text-weight-medium">Reading Crosshair</q-item-label><q-item-label caption>Dynamic highlight on hover</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="crosshairActive" color="indigo-8"></q-toggle></q-item-section>
</q-item>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label class="text-weight-medium">Show EOD Targets</q-item-label><q-item-label caption>Top row KPI</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="showEodTargets" color="pink-6"></q-toggle></q-item-section>
</q-item>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label class="text-weight-medium">Show Availability</q-item-label><q-item-label caption>Traffic light indicators</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="showAvailability" color="orange-6"></q-toggle></q-item-section>
</q-item>
</div>
<q-separator></q-separator>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label class="text-weight-medium">Compact Grid</q-item-label><q-item-label caption>High density view</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="isCompact" color="indigo-8"></q-toggle></q-item-section>
</q-item>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label class="text-weight-medium">Working Weekends</q-item-label><q-item-label caption>Enable Sat/Sun shifts</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="weekendsAreWorkingDays" color="indigo-8"></q-toggle></q-item-section>
</q-item>
<q-btn outline rounded color="indigo-8" label="Reset to Today" icon="today" class="full-width q-mt-md" @click="resetToToday"></q-btn>
</div>
</div>
</q-scroll-area>
</q-drawer>
<q-drawer v-model="filterDrawer" side="right" bordered :width="300" class="bg-grey-1">
<q-scroll-area class="fit">
<div class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-h6 text-weight-bold text-indigo-10">Filters</div>
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="filterDrawer = false"></q-btn>
</div>
<div class="text-caption text-grey-6 q-mb-sm">Refine the agent list by skills and expertise.</div>
<q-btn flat rounded color="red-5" label="Clear All Filters" class="full-width q-mb-lg" size="sm" icon="filter_alt_off" @click="clearFilters"></q-btn>
<div class="filter-section-title">Saved Views</div>
<q-list dense class="q-gutter-y-xs">
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('all')">
<q-item-section avatar><q-icon name="people_outline" size="xs"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">All Agents</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('high_potential')">
<q-item-section avatar><q-icon name="star_outline" size="xs" color="amber-8"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">High Expertise</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('remote')">
<q-item-section avatar><q-icon name="house_siding" size="xs" color="blue-6"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">Remote Support</q-item-section>
</q-item>
</q-list>
<div class="filter-section-title q-mt-lg">Shift Availability</div>
<div class="bg-white q-pa-sm rounded-borders border q-mb-md">
<q-toggle v-model="filterByAvailability" label="Filter by Active Shift" dense class="q-mb-sm text-weight-medium text-caption" color="green-6"></q-toggle>
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-xs animated fadeIn">
<q-input v-model="filterDate" filled dense label="Target Date" class="text-caption">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale" @before-show="updateFilterDateProxy">
<div class="column bg-white">
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal color="indigo-8" :first-day-of-week="pickerStartDay">
<div class="row items-center justify-end q-pa-sm border-t q-gutter-sm">
<q-btn v-close-popup label="Cancel" color="grey-7" flat dense></q-btn>
<q-btn v-close-popup label="OK" color="indigo-8" flat dense @click="applyFilterDate"></q-btn>
</div>
</q-date>
</div>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
<div class="text-caption text-grey-7 q-mb-xs">Required Shift Type:</div>
<div class="row q-gutter-xs">
<q-checkbox v-for="s in shifts" :key="s.id" v-model="filterShiftTypes" :val="s.id" :label="s.label" dense size="xs" :color="s.color.split('-')[0]" class="col-12 text-caption"></q-checkbox>
</div>
</div>
</div>
<!-- RE-ADDED: Hub Filter UI -->
<div class="filter-section-title">Hub Location</div>
<q-select v-model="activeHub" :options="hubOptions" emit-value map-options outlined dense rounded bg-color="white"></q-select>
<div class="filter-section-title">Primary Division</div>
<q-select v-model="activeDept" :options="['All', ...depts]" outlined dense rounded bg-color="white"></q-select>
<div class="filter-section-title">Roles</div>
<q-select v-model="filterRoles" :options="roles" multiple outlined dense rounded use-chips bg-color="white" placeholder="Filter by role..."></q-select>
<div class="filter-section-title">Skills & Expertise</div>
<q-select v-model="filterSkills" :options="allSkills" multiple outlined dense rounded use-chips bg-color="white" placeholder="Select expertise..."></q-select>
</div>
</q-scroll-area>
</q-drawer>
<q-drawer v-model="rightDrawer" side="right" bordered :width="380" overlay elevated>
<div class="column full-height bg-white">
<q-toolbar class="bg-white border-b q-px-md" style="height: 64px">
<q-toolbar-title class="text-subtitle1 text-weight-bold">{{ editMode === 'assignment' ? 'Edit Assignment' : 'Agent Details' }}</q-toolbar-title>
<q-btn flat round dense icon="close" color="grey-5" @click="rightDrawer = false"></q-btn>
</q-toolbar>
<q-scroll-area class="col q-pa-lg">
<div v-if="selectedAgent" class="row items-center q-mb-xl">
<q-avatar size="64px" class="shadow-1"><img :src="selectedAgent.avatar"></q-avatar>
<div class="q-ml-md">
<div class="text-h6 text-weight-bold">{{ selectedAgent.name }}</div>
<div class="text-caption text-grey-7">{{ selectedAgent.dept }} • {{ selectedAgent.role }}</div>
<div class="text-caption text-indigo-8 text-weight-bold q-mt-xs">
<q-icon name="public" size="xs" class="q-mr-xs"></q-icon> {{ selectedAgent.hubName }}
</div>
</div>
</div>
<template v-if="editMode === 'assignment' && selectedDate">
<div class="q-mb-lg q-pa-md bg-blue-grey-1 rounded-borders border">
<div class="row items-center justify-between no-wrap">
<div>
<div class="text-overline text-blue-grey-4">Shift Date</div>
<div class="text-subtitle1 text-weight-bold text-blue-grey-9">{{ selectedDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) }}</div>
</div>
<q-icon name="event" color="blue-grey-2" size="sm"></q-icon>
</div>
</div>
<div class="text-weight-bold q-mb-md">Standard Shifts</div>
<div class="row q-col-gutter-sm q-mb-xl">
<div v-for="s in shifts" :key="s.id" class="col-6"><q-btn :color="s.color" :unelevated="currentAssignmentLabel === s.label" :outline="currentAssignmentLabel !== s.label" rounded dense no-caps :label="s.label" class="full-width q-py-sm" @click="setPendingShift(s)"></q-btn></div>
<div class="col-12"><q-btn outline rounded dense color="red-4" label="Clear Assignment" class="full-width q-mt-sm" icon="delete_outline" @click="setPendingShift(null)"></q-btn></div>
</div>
<div class="text-weight-bold q-mb-sm text-grey-7">Cell Identifier</div>
<div class="bg-grey-1 q-pa-sm rounded-borders border text-[10px] text-grey-6 font-mono break-all">#cell-{{selectedAgent.id}}-{{formatDateForId(selectedDate)}}</div>
</template>
</q-scroll-area>
<div class="q-pa-lg border-t row q-gutter-md bg-grey-1">
<q-btn flat rounded label="Cancel" class="col" @click="rightDrawer = false"></q-btn>
<q-btn unelevated rounded color="indigo-10" label="Save Assignment" class="col" @click="saveAssignment"></q-btn>
</div>
</div>
</q-drawer>
<q-page-container>
<q-page class="q-pa-none">
<!-- LOADING OVERLAY -->
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="indigo-8"></q-spinner-gears>
<div class="text-indigo-8 q-mt-sm">Loading Agents...</div>
</q-inner-loading>
<!-- Main Scrollable Area using Virtual Scroll target -->
<div id="viewport-target" class="grid-viewport" ref="viewport" :style="gridStyles">
<div class="planner-content">
<div v-if="showEodTargets" class="planner-row row-eod">
<div class="left-col text-overline text-weight-bold">EOD Targets</div>
<div class="cells-area">
<div v-for="(d, i) in dates" :key="'e'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-eod)">
<div class="text-weight-bold text-[10px] text-pink-7">94%</div>
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded style="width: 60%"></q-linear-progress>
</div>
</div>
</div>
<div v-if="showAvailability" class="planner-row row-status">
<div class="left-col text-overline text-grey-5">Availability</div>
<div class="cells-area">
<div v-for="(d, i) in dates" :key="'s'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-status)"><q-icon name="circle" :color="i % 10 === 0 ? 'red-3' : (i % 5 === 0 ? 'orange-3' : 'green-3')" size="8px"></q-icon></div>
</div>
</div>
<div class="planner-row row-dates">
<div class="left-col">
<div class="grid-search-container">
<q-input v-model="search" debounce="300" dense outlined rounded :placeholder="$q.screen.lt.sm ? 'Search' : 'Quick search...'" class="full-width" bg-color="white" clearable>
<template v-slot:prepend><q-icon name="search" size="xs" color="grey-5"></q-icon></template>
</q-input>
<q-btn flat round dense icon="filter_alt" :color="isFilterActive ? 'indigo-10' : 'grey-6'" :class="isFilterActive ? 'filter-btn-active' : ''" size="sm" @click="filterDrawer = !filterDrawer">
<div v-if="isFilterActive" class="filter-status-dot"></div>
<q-tooltip>{{ isFilterActive ? activeFilterCount + ' filters active' : 'Advanced Filters & Expertise' }}</q-tooltip>
</q-btn>
<q-btn flat round dense icon="calendar_today" color="indigo-7" size="sm">
<q-menu v-model="dateMenu" @show="syncProxyDate">
<q-card class="dense-date-card shadow-12 border">
<q-card-section class="q-pa-none"><q-date v-model="proxyDate" minimal flat color="indigo-8" :first-day-of-week="pickerStartDay"></q-date></q-card-section>
<q-card-actions align="right" class="q-pt-none q-pb-sm q-px-md"><q-btn flat dense rounded label="Cancel" color="grey-7" @click="dateMenu = false"></q-btn><q-btn unelevated dense rounded label="OK" color="indigo-8" class="q-px-md" @click="applyDateSelection"></q-btn></q-card-actions>
</q-card>
</q-menu>
</q-btn>
</div>
</div>
<div class="cells-area">
<div v-for="(date, i) in dates"
:key="'d'+i"
class="cell column flex-center relative-position date-header-hover-trigger transition-colors"
:class="[
isWeekend(date) && !weekendsAreWorkingDays ? 'bg-weekend' : '',
highlightedDateStr === formatDateForId(date) ? 'reading-active-header' : '',
crosshairActive && hoveredDateStr === formatDateForId(date) ? 'col-hovered' : ''
]"
style="height: var(--h-dates)"
@mouseenter="hoveredDateStr = formatDateForId(date)"
@mouseleave="hoveredDateStr = null"
>
<div class="absolute-top-right q-pa-xs">
<q-icon v-if="getHoliday(date)" name="celebration" size="10px" color="red-5" class="cursor-help"><q-tooltip class="bg-red-9 text-white shadow-4 q-pa-sm">Holiday: {{ getHoliday(date) }}</q-tooltip></q-icon>
<q-icon v-else-if="getSpecialDay(date)" name="bookmark" size="10px" color="indigo-4" class="cursor-help"><q-tooltip class="bg-indigo-9 text-white shadow-4 q-pa-sm">Event: {{ getSpecialDay(date) }}</q-tooltip></q-icon>
</div>
<div class="text-[10px] uppercase text-weight-bold" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-5']">{{ date.toLocaleDateString('en-US', {weekday: 'short'}) }}</div>
<div class="text-subtitle2 text-weight-bold leading-none" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-9']">{{ date.getDate() }}. {{ date.toLocaleDateString('en-US', { month: 'short' }) }}</div>
<q-btn round flat dense
:icon="highlightedDateStr === formatDateForId(date) ? 'visibility_off' : 'visibility'"
:color="highlightedDateStr === formatDateForId(date) ? 'amber-9' : 'grey-5'"
size="sm"
class="header-highlight-btn absolute-bottom-right q-ma-xs"
:class="{ 'is-active': highlightedDateStr === formatDateForId(date) }"
@click.stop="toggleColHighlight(date)">
<q-tooltip>{{ highlightedDateStr === formatDateForId(date) ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
</q-btn>
<div v-if="highlightedDateStr === formatDateForId(date)" class="absolute-bottom full-width bg-amber-8" style="height: 3px;"></div>
</div>
</div>
</div>
<!-- LOADING SKELETON STATE -->
<div v-if="loading" class="q-pa-none">
<div v-for="n in 18" :key="n" class="planner-row">
<div class="left-col border-b" style="height: 59px">
<div class="row items-center full-width">
<q-skeleton type="QAvatar" size="32px" class="q-mr-sm" />
<div class="col">
<q-skeleton type="text" width="60%" />
<q-skeleton type="text" width="40%" height="10px" />
</div>
</div>
</div>
<div class="cells-area">
<div v-for="c in 8" :key="c" class="cell">
<q-skeleton v-if="Math.random() > 0.6" type="rect" height="20px" width="80%" class="rounded-borders" style="opacity: 0.3" />
</div>
</div>
</div>
</div>
<!-- VIRTUAL SCROLL IMPLEMENTATION -->
<q-virtual-scroll
v-else
scroll-target="#viewport-target"
:items="flattenedList"
:item-size="isCompact ? 43 : 59"
class="virtual-scroll-target"
>
<template v-slot="{ item, index }">
<!-- Level 1 Header (e.g. HUB) -->
<div v-if="item.type === 'header-l1'" :key="item.id" class="planner-row group-header-l1">
<div class="left-col group-header-l1">{{ item.label }}</div>
<div class="cells-area"><div class="cell" style="flex: 1; height: 36px; border-right: none"></div></div>
</div>
<!-- Level 2 Header (e.g. DIVISION) -->
<div v-else-if="item.type === 'header-l2'" :key="item.id" class="planner-row group-header-l2">
<div class="left-col group-header-l2">{{ item.label }}</div>
<div class="cells-area"><div class="cell" style="flex: 1; height: 32px; border-right: none"></div></div>
</div>
<!-- Agent Row -->
<div
v-else
:key="item.id"
class="planner-row planner-row-item"
:class="{'row-highlighted': highlightedRowId === item.data.id, 'crosshair-enabled': crosshairActive}"
>
<div class="left-col border-b cursor-pointer agent-row-hover-trigger relative-position"
:class="[isCompact ? 'cell-compact' : '']"
@click="openProfile(item.data)">
<q-avatar :size="isCompact ? '24px' : '32px'" class="shadow-1"><img :src="item.data.avatar"></q-avatar>
<div class="q-ml-sm overflow-hidden col">
<div class="text-weight-bold truncate" :style="{fontSize: isCompact ? '11px' : '13px'}">{{ item.data.name }}</div>
<div v-if="!isCompact" class="text-[10px] text-grey-5 uppercase text-weight-bold truncate agent-role">{{ item.data.role }}</div>
</div>
<q-btn round flat dense
:icon="highlightedRowId === item.data.id ? 'visibility_off' : 'visibility'"
:color="highlightedRowId === item.data.id ? 'amber-9' : 'grey-4'"
size="sm"
class="agent-highlight-btn"
:class="{ 'is-active': highlightedRowId === item.data.id }"
@click.stop="toggleRowHighlight(item.data.id)">
<q-tooltip>{{ highlightedRowId === item.data.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
</q-btn>
</div>
<div class="cells-area">
<div v-for="(date, i) in dates"
:key="'c'+item.data.id+i"
class="cell"
:class="getCellClass(item.data.id, date)"
@mouseenter="hoveredDateStr = formatDateForId(date)"
@mouseleave="hoveredDateStr = null"
@click="openAssignment(item.data, date)">
<template v-if="weekendsAreWorkingDays || !isWeekend(date)"><div v-if="getAssignment(item.data.id, date)" class="shift-badge shadow-1" :class="getAssignment(item.data.id, date).badgeClass">{{ getAssignment(item.data.id, date).label }}</div></template>
<!-- Updated lock icon to check reactive state -->
<div v-if="isCellLocked(item.data.id, date)" class="locked-overlay"><q-icon name="lock" color="blue-grey-3" size="14px"><q-tooltip class="bg-grey-9 text-white shadow-4 q-pa-sm">This cell is currently being edited by another user.</q-tooltip></q-icon></div>
<div class="absolute-bottom-right q-pa-xs row no-wrap" style="gap: 2px">
<q-icon v-if="hasComment(item.data.id, date)" name="chat_bubble" size="8px" color="blue-grey-3" class="cursor-help opacity-60"><q-tooltip class="bg-white text-grey-9 border shadow-4 shift-tooltip" anchor="top middle" self="bottom middle"><div class="text-weight-bold text-caption text-indigo-9 q-mb-xs">User Comment</div><div class="text-caption text-grey-8">{{ getCommentText(item.data.id, date) }}</div></q-tooltip></q-icon>
<q-icon v-if="hasNote(item.data.id, date)" name="info" size="8px" color="orange-4" class="cursor-help opacity-70"><q-tooltip class="bg-white text-grey-9 border shadow-4 shift-tooltip" anchor="top middle" self="bottom middle"><div class="text-weight-bold text-caption text-orange-9 q-mb-xs">Technical Note</div><div class="text-caption text-grey-8">{{ getNoteText(item.data.id, date) }}</div></q-tooltip></q-icon>
</div>
</div>
</div>
</div>
</template>
</q-virtual-scroll>
</div>
</div>
</q-page>
</q-page-container>
</q-layout>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
<script>
const { createApp, ref, computed, nextTick, reactive, onMounted } = Vue;
const { useQuasar } = Quasar;
const HUBS = [
{ id: 'DE', name: 'GERMANY HUB (DE)' },
{ id: 'IT', name: 'ITALY HUB (IT)' },
{ id: 'FR', name: 'FRANCE HUB (FR)' },
{ id: 'GB', name: 'UNITED KINGDOM (GB)' },
{ id: 'ES', name: 'SPAIN HUB (ES)' },
{ id: 'AE', name: 'UAE HUB (AE)' },
{ id: 'PL', name: 'POLAND HUB (PL)' }
];
const DEPARTMENTS = ["Support", "Technical", "Sales", "VIP", "Billing"];
const ROLES = ["Senior Lead", "Specialist", "Agent"];
const SKILLS = ["English", "German", "French", "Molecular App", "Hardware", "Billing Specialist", "VIP Concierge", "Technical Training"];
const SHIFTS = [
{ id: 'm', label: 'MORNING', color: 'green-7', badgeClass: 'bg-green-1 text-green-9 border-green-2' },
{ id: 'a', label: 'AFTERNOON', color: 'blue-7', badgeClass: 'bg-blue-1 text-blue-9 border-blue-2' },
{ id: 'h', label: 'HOTLINE', color: 'indigo-7', badgeClass: 'bg-indigo-1 text-indigo-9 border-indigo-2' },
{ id: 'e', label: 'EOD ONLY', color: 'pink-7', badgeClass: 'bg-pink-1 text-pink-9 border-pink-2' }
];
// --- REFACTORED: Generate Agents for Mock DB ---
// Note: We are NOT calling this immediately. We wait for loadDataFromDatabase()
const generateMockAgents = (count) => {
return Array.from({ length: count }, (_, i) => {
const hub = HUBS[Math.floor(Math.random() * HUBS.length)];
return {
id: i + 1,
name: `Agent ${i + 1}`,
dept: DEPARTMENTS[i % DEPARTMENTS.length],
role: ROLES[i % ROLES.length],
hub: hub.id,
hubName: hub.name,
skills: [SKILLS[i % SKILLS.length], SKILLS[(i + 2) % SKILLS.length]],
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=Agent${i}`
};
});
};
const MOCK_COMMENTS_TEXT = ["Late arrival expected.", "Dental appointment.", "Swapped shift.", "Priority focus.", "Remote session."];
const MOCK_NOTES_TEXT = ["Headset check.", "VPN slow.", "Training session.", "Backup Billing.", "Overtime pending."];
// Removed static mocks to use dynamic generation inside loadDataFromDatabase
const app = createApp({
setup() {
const $q = useQuasar();
const leftDrawer = ref(false);
const filterDrawer = ref(false);
const rightDrawer = ref(false);
const editMode = ref('assignment');
const viewport = ref(null);
const isCompact = ref(false);
const weekendsAreWorkingDays = ref(false);
const viewScope = ref(8);
const pickerStartDay = ref(1);
const search = ref("");
const activeDept = ref("All");
const activeHub = ref("All");
const filterRoles = ref([]);
const filterSkills = ref([]);
// --- ASYNC DATA STATE ---
const agents = ref([]); // Replaces constant AGENTS
const loading = ref(false);
const lockedCells = ref(new Set()); // Stores "agentId:YYYY-MM-DD" for WSS Locks
// Reactive data stores for backend data
const assignments = reactive({});
const comments = reactive({});
const notes = reactive({});
const holidays = reactive({});
const specialDays = reactive({});
// --- GROUPING LOGIC STATE ---
const groupingSelection = ref(['hub', 'dept']);
const groupingOptions = [
{ label: 'Hub > Division (Default)', value: ['hub', 'dept'] },
{ label: 'Division > Role', value: ['dept', 'role'] },
{ label: 'Hub > Role', value: ['hub', 'role'] },
{ label: 'Division (Flat)', value: ['dept'] },
{ label: 'Role (Flat)', value: ['role'] },
{ label: 'No Grouping (Flat)', value: [] }
];
const hubOptions = [
{ label: 'All Hubs', value: 'All' },
...HUBS.map(h => ({ label: h.name, value: h.id }))
];
const showEodTargets = ref(false);
const showAvailability = ref(false);
const selectedAgent = ref(null);
const selectedDate = ref(null);
const pendingShift = ref(null);
const dateMenu = ref(false);
const proxyDate = ref(null);
const highlightedRowId = ref(null);
const highlightedDateStr = ref(null);
const crosshairActive = ref(true);
const hoveredDateStr = ref(null);
const filterByAvailability = ref(false);
const formatDateForId = (date) => {
if (!date) return '';
const d = new Date(date);
const month = '' + (d.getMonth() + 1);
const day = '' + d.getDate();
const year = d.getFullYear();
return [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');
};
const filterDate = ref(formatDateForId(new Date()));
const proxyFilterDate = ref('');
const filterShiftTypes = ref([]);
const getStartOfWeek = (date) => {
const d = new Date(date);
const day = d.getDay();
const diff = (day < 1 ? 7 : 0) + day - 1;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
};
const startDate = ref(getStartOfWeek(new Date()));
const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
const dates = computed(() => {
const res = [];
const now = new Date(startDate.value);
for (let i = 0; i < viewScope.value * 7; i++) {
const d = new Date(now);
d.setDate(now.getDate() + i);
res.push(d);
}
return res;
});
const gridStyles = computed(() => {
return {
'--h-eod': showEodTargets.value ? '42px' : '0px',
'--h-status': showAvailability.value ? '34px' : '0px'
};
});
// --- MOCK DATABASE LOADER ---
const loadDataFromDatabase = () => {
loading.value = true;
// Increased to 2000ms for visibility
setTimeout(() => {
const fetchedAgents = generateMockAgents(800);
agents.value = fetchedAgents;
// Populate Shifts, Comments, Notes for a range around start date
const mockStartDate = new Date(startDate.value);
// Simple loop to fill some data
fetchedAgents.forEach(agent => {
// Init containers
if (!assignments[agent.id]) assignments[agent.id] = {};
if (!comments[agent.id]) comments[agent.id] = {};
if (!notes[agent.id]) notes[agent.id] = {};
// Generate data for next 60 days
for (let i = 0; i < 60; i++) {
const d = new Date(mockStartDate);
d.setDate(d.getDate() + i);
const dStr = formatDateForId(d);
// Shift Logic: Random pattern
if ((agent.id + i) % 7 === 0) assignments[agent.id][dStr] = SHIFTS[0].id;
else if ((agent.id + i) % 5 === 0) assignments[agent.id][dStr] = SHIFTS[1].id;
// Comment Logic: Sparse
if ((agent.id + i) % 20 === 0) comments[agent.id][dStr] = MOCK_COMMENTS_TEXT[(agent.id + i) % MOCK_COMMENTS_TEXT.length];
// Note Logic: Sparse
if ((agent.id + i) % 25 === 0) notes[agent.id][dStr] = MOCK_NOTES_TEXT[(agent.id + i) % MOCK_NOTES_TEXT.length];
}
});
// UPDATED: Populate Dynamic Holidays & Special Days relative to NOW
// Clear old ones first if re-loading
for (const key in holidays) delete holidays[key];
for (const key in specialDays) delete specialDays[key];
const today = new Date();
// Add a holiday for Tomorrow
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
holidays[formatDateForId(tomorrow)] = 'Regional Holiday';
// Add a special event for 5 days from now
const eventDay = new Date(today);
eventDay.setDate(today.getDate() + 5);
specialDays[formatDateForId(eventDay)] = 'Quarterly Planning';
loading.value = false;
$q.notify({ message: 'Data Loaded: 800 Agents', color: 'positive', position: 'top', timeout: 1000 });
}, 2000); // 2 seconds delay
};
// --- WSS SIMULATION ---
const handleWssMessage = (type, payload) => {
if (type === 'LOCK_CELL') {
const key = `${payload.agentId}:${payload.date}`;
lockedCells.value.add(key);
// Trigger notify to show it happened
const agent = agents.value.find(a => a.id === payload.agentId);
const name = agent ? agent.name : 'Unknown Agent';
$q.notify({
message: `WSS: Cell Locked for ${name} on ${payload.date}`,
color: 'negative',
position: 'top',
icon: 'lock'
});
}
};
const simulateWssLock = () => {
if (agents.value.length === 0) return;
// Pick random agent
const randomAgent = agents.value[Math.floor(Math.random() * agents.value.length)];
// Pick today, ensure it's a weekday
let targetDate = new Date();
const day = targetDate.getDay();
if (day === 0) { // Sunday -> move to Monday
targetDate.setDate(targetDate.getDate() + 1);
} else if (day === 6) { // Saturday -> move to Monday
targetDate.setDate(targetDate.getDate() + 2);
}
// If it's Saturday (6), add 2 days. If Sunday (0), add 1 day.
const dateStr = formatDateForId(targetDate);
handleWssMessage('LOCK_CELL', { agentId: randomAgent.id, date: dateStr });
};
const isCellLocked = (agentId, date) => {
const key = `${agentId}:${formatDateForId(date)}`;
return lockedCells.value.has(key);
};
const getAssignment = (agentId, date) => {
const dateStr = formatDateForId(date);
if (assignments[agentId] && assignments[agentId][dateStr] !== undefined) {
return SHIFTS.find(s => s.id === assignments[agentId][dateStr]);
}
return null;
};
const getHoliday = (date) => holidays[formatDateForId(date)] || null;
const getSpecialDay = (date) => specialDays[formatDateForId(date)] || null;
const hasComment = (agentId, date) => {
const dStr = formatDateForId(date);
return comments[agentId] && comments[agentId][dStr];
};
const hasNote = (agentId, date) => {
const dStr = formatDateForId(date);
return notes[agentId] && notes[agentId][dStr];
};
const getCommentText = (agentId, date) => {
const dStr = formatDateForId(date);
return comments[agentId] ? comments[agentId][dStr] : '';
};
const getNoteText = (agentId, date) => {
const dStr = formatDateForId(date);
return notes[agentId] ? notes[agentId][dStr] : '';
};
const isFilterActive = computed(() => filterRoles.value.length > 0 || filterSkills.value.length > 0 || activeDept.value !== 'All' || activeHub.value !== 'All' || (search.value && search.value.length > 0) || filterByAvailability.value);
const activeFilterCount = computed(() => {
let count = 0;
if (search.value && search.value.length > 0) count++;
if (activeDept.value !== 'All') count++;
if (activeHub.value !== 'All') count++;
if (filterRoles.value.length > 0) count++;
if (filterSkills.value.length > 0) count++;
if (filterByAvailability.value) count++;
return count;
});
// 1. FILTERING LOGIC
const filteredAgents = computed(() => {
return agents.value.filter(a => {
const term = (search.value || "").toLowerCase();
const matchSearch = term === "" || a.name.toLowerCase().includes(term);
const matchDept = activeDept.value === 'All' || a.dept === activeDept.value;
const matchHub = activeHub.value === 'All' || a.hub === activeHub.value;
const matchRoles = filterRoles.value.length === 0 || filterRoles.value.includes(a.role);
const matchSkills = filterSkills.value.length === 0 || a.skills.some(s => filterSkills.value.includes(s));
let matchAvailability = true;
if (filterByAvailability.value && filterDate.value) {
const d = new Date(filterDate.value);
const assignment = getAssignment(a.id, d);
if (!weekendsAreWorkingDays.value && isWeekend(d)) {
matchAvailability = false;
} else if (!assignment) {
matchAvailability = false;
} else {
if (filterShiftTypes.value.length > 0 && !filterShiftTypes.value.includes(assignment.id)) {
matchAvailability = false;
}
}
}
return matchSearch && matchDept && matchHub && matchRoles && matchSkills && matchAvailability;
});
});
// 2. DYNAMIC MULTI-LEVEL GROUPING LOGIC
const flattenedList = computed(() => {
const result = [];
const list = [...filteredAgents.value];
const keys = groupingSelection.value;
// Helper to get a human-readable label
const getLabel = (key, value) => {
if (key === 'hub') return HUBS.find(h => h.id === value)?.name || value;
if (key === 'dept') return value.toUpperCase() + ' DIVISION';
if (key === 'role') return value + 's';
return value;
};
if (keys.length === 0) {
return list.map(a => ({ type: 'agent', data: a, id: a.id }));
}
list.sort((a, b) => {
for (const key of keys) {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
}
return 0;
});
const currentValues = keys.map(() => null);
list.forEach(agent => {
let changedLevel = -1;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (agent[key] !== currentValues[i]) {
changedLevel = i;
break;
}
}
if (changedLevel !== -1) {
for (let i = changedLevel; i < keys.length; i++) {
const key = keys[i];
const val = agent[key];
currentValues[i] = val;
const type = i === 0 ? 'header-l1' : 'header-l2';
result.push({
type: type,
label: getLabel(key, val),
id: `hdr-${key}-${val}-${Math.random()}`
});
}
}
result.push({ type: 'agent', data: agent, id: agent.id });
});
return result;
});
const currentAssignmentLabel = computed(() => {
if (pendingShift.value) return pendingShift.value.label;
if (!selectedAgent.value || !selectedDate.value) return null;
const a = getAssignment(selectedAgent.value.id, selectedDate.value);
return a ? a.label : null;
});
const clearFilters = () => {
search.value = "";
activeDept.value = "All";
activeHub.value = "All";
filterRoles.value = [];
filterSkills.value = [];
filterByAvailability.value = false;
filterShiftTypes.value = [];
filterDate.value = formatDateForId(new Date());
};
const applySavedFilter = (key) => { clearFilters(); if (key === 'high_potential') { filterSkills.value = ["VIP Concierge", "Technical Training"]; } else if (key === 'remote') { filterRoles.value = ["Specialist"]; } };
const syncProxyDate = () => { proxyDate.value = formatDateForId(selectedDate.value || new Date()); };
const applyDateSelection = () => { if (!proxyDate.value) return; const target = new Date(proxyDate.value); startDate.value = getStartOfWeek(target); if (rightDrawer.value && editMode.value === 'assignment' && selectedAgent.value) { selectedDate.value = target; pendingShift.value = null; } dateMenu.value = false; nextTick(() => { if (viewport.value) viewport.value.scrollLeft = 0; }); };
const resetToToday = () => { const today = new Date(); startDate.value = getStartOfWeek(today); if (rightDrawer.value && editMode.value === 'assignment') { selectedDate.value = today; pendingShift.value = null; } if (viewport.value) viewport.value.scrollLeft = 0; leftDrawer.value = false; };
const updateFilterDateProxy = () => {
proxyFilterDate.value = filterDate.value;
};
const applyFilterDate = () => {
filterDate.value = proxyFilterDate.value;
};
const openAssignment = (agent, date) => { if (isCellLocked(agent.id, date) || (!weekendsAreWorkingDays.value && isWeekend(date))) return; editMode.value = 'assignment'; selectedAgent.value = agent; selectedDate.value = date; pendingShift.value = null; rightDrawer.value = true; };
const openProfile = (agent) => { editMode.value = 'profile'; selectedAgent.value = { ...agent }; selectedDate.value = null; rightDrawer.value = true; };
const setPendingShift = (shift) => { pendingShift.value = shift; };
const saveAssignment = () => { if (!selectedAgent.value || !selectedDate.value) return; const agentId = selectedAgent.value.id; const dateStr = formatDateForId(selectedDate.value); if (!assignments[agentId]) assignments[agentId] = {}; assignments[agentId][dateStr] = pendingShift.value ? pendingShift.value.id : null; pendingShift.value = null; rightDrawer.value = false; };
const toggleRowHighlight = (agentId) => {
highlightedRowId.value = highlightedRowId.value === agentId ? null : agentId;
};
const toggleColHighlight = (date) => {
const str = formatDateForId(date);
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
};
const clearHighlights = () => {
highlightedRowId.value = null;
highlightedDateStr.value = null;
};
const getCellClass = (agentId, date) => {
const isRow = highlightedRowId.value === agentId;
const isCol = highlightedDateStr.value === formatDateForId(date);
// CROSSHAIR LOGIC - ROW part handled by CSS :hover now
// We only need to check COLUMN hover in JS
const isHoverCol = crosshairActive.value && hoveredDateStr.value === formatDateForId(date);
const isWknd = isWeekend(date) && !weekendsAreWorkingDays.value;
const isLocked = isCellLocked(agentId, date);
const baseClasses = [];
if (isCompact.value) baseClasses.push('cell-compact');
if (isWknd) baseClasses.push('bg-weekend');
if (isWknd && !weekendsAreWorkingDays.value) baseClasses.push('cursor-not-allowed');
else if (isLocked) baseClasses.push('cursor-not-allowed');
else baseClasses.push('cursor-pointer');
if (isRow && isCol) baseClasses.push('bg-reading-mode-intersection');
else if (isRow || isCol) baseClasses.push('bg-reading-mode');
if (isHoverCol) baseClasses.push('col-hovered');
return baseClasses.join(' ');
};
// --- LIFECYCLE ---
onMounted(() => {
loadDataFromDatabase();
});
return {
leftDrawer, filterDrawer, rightDrawer, editMode, isCompact, weekendsAreWorkingDays,
viewScope, pickerStartDay, search, activeDept, isWeekend, viewport,
onlineUsers: ref(Array.from({ length: 32 }, (_, i) => ({ id: i, name: `User ${i}`, role: 'Planner', img: `https://i.pravatar.cc/150?u=${i+10}` }))),
visibleOnlineUsers: computed(() => Array.from({ length: 6 }, (_, i) => ({ id: i, name: `User ${i}`, role: 'Planner', img: `https://i.pravatar.cc/150?u=${i+10}` }))),
remainingOnlineCount: ref(26),
dates, depts: DEPARTMENTS, roles: ROLES, allSkills: SKILLS,
filterRoles, filterSkills, isFilterActive, activeFilterCount, clearFilters, applySavedFilter,
flattenedList,
openAssignment, openProfile, getAssignment, saveAssignment, setPendingShift,
selectedAgent, selectedDate, formatDateForId, currentAssignmentLabel,
dateMenu, proxyDate, syncProxyDate, applyDateSelection, resetToToday,
shifts: SHIFTS,
hasComment, hasNote, getCommentText, getNoteText, isCellLocked,
getHoliday, getSpecialDay,
highlightedRowId, highlightedDateStr, toggleRowHighlight, toggleColHighlight, clearHighlights, getCellClass,
filterByAvailability, filterDate, filterShiftTypes,
showEodTargets, showAvailability, gridStyles,
crosshairActive, hoveredDateStr,
proxyFilterDate, updateFilterDateProxy, applyFilterDate,
groupingSelection, groupingOptions,
activeHub, hubOptions,
// NEW EXPORTS
loading, simulateWssLock
};
}
});
app.use(Quasar, { config: { brand: { primary: '#334155' } } });
app.mount('#q-app');
</script>
</body>
</html>