Added new modules and updated existing logic

This commit is contained in:
Dieter Neumann
2026-02-24 13:32:01 +01:00
parent 2a4b4ed5fe
commit ad734273ce
694 changed files with 27935 additions and 610 deletions

View File

@@ -0,0 +1,140 @@
/* app-header — modern top toolbar with unified color scheme */
.app-header-root {
background: var(--background-primary) !important;
border-bottom: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-sm) !important;
}
.app-header-toolbar {
padding-top: var(--spacing-sm) !important;
padding-bottom: var(--spacing-sm) !important;
min-height: 56px !important;
}
.app-header-title {
font-weight: 700 !important;
font-size: 18px !important;
color: var(--primary-dark) !important;
max-width: 220px !important;
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.01em;
}
.app-header-subtitle {
font-size: 11px !important;
color: var(--text-tertiary) !important;
font-weight: 500;
margin-left: var(--spacing-sm);
background: none;
-webkit-text-fill-color: var(--text-tertiary);
}
.app-header-menu-btn {
color: var(--text-secondary) !important;
transition: all 0.2s ease !important;
border-radius: var(--radius-lg) !important;
}
.app-header-menu-btn:hover {
background-color: var(--hover-bg) !important;
color: var(--primary-color) !important;
transform: scale(1.05);
}
.app-header-highlight-btn {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--warning-color) !important;
background-color: rgba(245, 158, 11, 0.1) !important;
color: var(--warning-color) !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 4px 12px !important;
transition: all 0.2s ease !important;
}
.app-header-highlight-btn:hover {
background-color: rgba(245, 158, 11, 0.2) !important;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.app-header-notification-btn {
color: var(--text-secondary) !important;
border-radius: var(--radius-lg) !important;
transition: all 0.2s ease !important;
}
.app-header-notification-btn:hover {
background-color: var(--hover-bg) !important;
color: var(--primary-color) !important;
}
.app-header-avatar {
border: 2px solid var(--background-primary) !important;
box-shadow: 0 0 0 2px var(--primary-light) !important;
transition: all 0.3s ease !important;
border-radius: var(--radius-rounded) !important;
}
.app-header-avatar:hover {
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--primary-color), var(--shadow-md) !important;
}
.app-header-separator {
background-color: var(--border-color) !important;
margin-left: var(--spacing-md) !important;
margin-right: var(--spacing-md) !important;
}
/* Badge for notification count */
.app-header-badge {
position: absolute !important;
top: -4px !important;
right: -4px !important;
background-color: var(--danger-color) !important;
color: var(--text-white) !important;
font-size: 10px !important;
font-weight: 800 !important;
min-width: 16px !important;
height: 16px !important;
border-radius: var(--radius-rounded) !important;
border: 2px solid var(--background-primary) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Mobile optimizations */
@media (max-width: 599px) {
.app-header-toolbar {
min-height: 52px !important;
padding-left: var(--spacing-sm) !important;
padding-right: var(--spacing-sm) !important;
}
.app-header-title {
font-size: 16px !important;
max-width: 160px !important;
}
.app-header-separator {
display: none !important;
}
}
/* Animation for highlight button appearance */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.app-header-highlight-btn-enter-active {
animation: slideInRight 0.3s ease-out;
}

View File

@@ -0,0 +1,71 @@
/**
* app-header.js
* ==============
* Top toolbar: logo, online-users component, notifications,
* clear-highlights button.
*/
import {
leftDrawer,
highlightedRowId,
highlightedDateStr,
clearHighlights
} from '../../services/planner-state.js';
import OnlineUsers from '../online-users/online-users.js';
export default {
name: 'AppHeader',
components: { OnlineUsers },
setup() {
return {
leftDrawer,
highlightedRowId,
highlightedDateStr,
clearHighlights
};
},
template: `
<q-header class="app-header-root">
<q-toolbar class="app-header-toolbar">
<q-btn flat round dense icon="menu"
class="app-header-menu-btn"
@click="leftDrawer = !leftDrawer">
</q-btn>
<div class="row items-center q-ml-md">
<q-toolbar-title class="app-header-title ellipsis">
Hotline Planner
<span class="app-header-subtitle">opus 4.6</span>
</q-toolbar-title>
</div>
<q-space></q-space>
<div class="row items-center q-gutter-sm">
<!-- Clear highlights button -->
<transition appear enter-active-class="app-header-highlight-btn-enter-active" leave-active-class="animated fadeOut">
<q-btn v-if="highlightedRowId || highlightedDateStr"
outline dense
icon="highlight_off"
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
class="app-header-highlight-btn q-mr-xs"
@click="clearHighlights">
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
<q-badge v-if="$q.screen.gt.xs" rounded floating color="warning" class="q-ml-xs">!</q-badge>
</q-btn>
</transition>
<!-- Online users (reactive, WSS-driven) -->
<online-users></online-users>
<q-separator vertical inset class="app-header-separator gt-xs"></q-separator>
<q-btn flat round dense icon="notifications" class="app-header-notification-btn gt-xs relative-position">
<q-badge rounded color="danger" class="app-header-badge">3</q-badge>
</q-btn>
<q-avatar size="32px" class="app-header-avatar cursor-pointer">
<img src="https://i.pravatar.cc/150?u=me">
</q-avatar>
</div>
</q-toolbar>
</q-header>
`
};

View File

@@ -0,0 +1,5 @@
/* assignment-drawer — right overlay for editing shifts */
.assignment-drawer-root {
/* intentionally empty — styled via Quasar props */
}

View File

@@ -0,0 +1,119 @@
/**
* assignment-drawer.js
* =====================
* Right overlay drawer: agent profile, shift assignment buttons,
* save / cancel actions.
*/
import {
rightDrawer,
editMode,
selectedAgent,
selectedDate,
pendingShift,
formatDateForId
} from '../../services/planner-state.js';
import {
SHIFTS,
getAssignment,
assignments,
saveAssignment as doSave
} from '../../services/data-service.js';
export default {
name: 'AssignmentDrawer',
setup() {
const currentAssignmentLabel = Vue.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 setPendingShift = (shift) => { pendingShift.value = shift; };
const saveAssignment = () => {
doSave(selectedAgent, selectedDate, pendingShift, rightDrawer);
};
return {
rightDrawer,
editMode,
selectedAgent,
selectedDate,
pendingShift,
formatDateForId,
shifts: SHIFTS,
currentAssignmentLabel,
setPendingShift,
saveAssignment
};
},
template: `
<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 }} &bull; {{ 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>
`
};

View File

@@ -0,0 +1,167 @@
/* date-header — modern date column header */
.date-header-root {
width: var(--cell-width);
min-width: var(--cell-width);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
height: var(--h-dates);
border-right: 1px solid var(--border-color-light);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--background-secondary);
cursor: pointer;
}
.date-header-root:hover {
background: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1);
z-index: 5;
}
.date-header-bg-weekend {
background-color: var(--background-tertiary) !important;
}
.date-header-text-sat {
color: var(--success-color) !important;
font-weight: 600 !important;
}
.date-header-text-sun {
color: var(--danger-color) !important;
font-weight: 600 !important;
}
.date-header-reading-active {
background-color: rgba(255, 249, 196, 0.9) !important;
border-bottom: 2px solid var(--warning-color);
box-shadow: inset 0 -2px 0 var(--warning-color);
}
.date-header-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: 10;
}
.date-header-hover-trigger .date-header-highlight-btn {
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: scale(0.9);
}
.date-header-hover-trigger:hover .date-header-highlight-btn,
.date-header-highlight-btn.date-header-is-active {
opacity: 1;
transform: scale(1);
}
.date-header-active-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, var(--primary-color), var(--info-color));
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
animation: active-bar-pulse 2s ease-in-out infinite;
}
@keyframes active-bar-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.date-header-day {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: var(--spacing-xs);
}
.date-header-month {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.date-header-weekday {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.date-header-today {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.1), rgba(14, 165, 233, 0.1)) !important;
border: 1px solid rgba(79, 70, 229, 0.2) !important;
box-shadow: 0 2px 6px -1px rgba(79, 70, 229, 0.2) !important;
}
.date-header-today .date-header-day {
color: var(--primary-color) !important;
}
.date-header-today .date-header-month {
color: var(--primary-dark) !important;
}
.date-header-today .date-header-weekday {
color: var(--info-color) !important;
}
.date-header-highlight-btn {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
width: 20px;
height: 20px;
border-radius: var(--radius-rounded);
background: var(--background-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
transition: all 0.2s ease;
z-index: 15;
}
.date-header-highlight-btn:hover {
background: var(--primary-light);
color: var(--primary-color);
border-color: var(--primary-color);
transform: scale(1.1);
}
.date-header-highlight-btn.date-header-is-active {
background: var(--warning-color);
color: var(--text-white);
border-color: var(--warning-color);
}
/* Mobile optimizations */
@media (max-width: 599px) {
.date-header-root {
height: calc(var(--h-dates) - 8px);
}
.date-header-day {
font-size: 16px;
}
.date-header-month {
font-size: 9px;
}
.date-header-weekday {
font-size: 10px;
}
}

View File

@@ -0,0 +1,101 @@
/**
* date-header.js
* ===============
* A single date-column header cell.
* Shows weekday, day number, holiday / event icons, highlight toggle.
*/
import {
formatDateForId,
isWeekend,
weekendsAreWorkingDays,
crosshairActive,
hoveredDateStr,
highlightedDateStr,
toggleColHighlight
} from '../../services/planner-state.js';
import { getHoliday, getSpecialDay } from '../../services/data-service.js';
export default {
name: 'DateHeader',
props: {
date: { type: Date, required: true }
},
setup(props) {
const dateStr = Vue.computed(() => formatDateForId(props.date));
const rootClass = Vue.computed(() => {
const cls = ['date-header-root', 'date-header-hover-trigger'];
if (isWeekend(props.date) && !weekendsAreWorkingDays.value) cls.push('date-header-bg-weekend');
if (highlightedDateStr.value === dateStr.value) cls.push('date-header-reading-active');
if (crosshairActive.value && hoveredDateStr.value === dateStr.value) cls.push('date-header-col-hovered');
return cls.join(' ');
});
const isWkndNonWorking = Vue.computed(() => isWeekend(props.date) && !weekendsAreWorkingDays.value);
const isSat = Vue.computed(() => props.date.getDay() === 6);
const dayLabelClass = Vue.computed(() => {
if (isWkndNonWorking.value) return isSat.value ? 'date-header-text-sat' : 'date-header-text-sun';
return 'text-grey-5';
});
const dayNumClass = Vue.computed(() => {
if (isWkndNonWorking.value) return isSat.value ? 'date-header-text-sat' : 'date-header-text-sun';
return 'text-grey-9';
});
const weekdayShort = Vue.computed(() =>
props.date.toLocaleDateString('en-US', { weekday: 'short' })
);
const dayNum = Vue.computed(() => props.date.getDate());
const monthShort = Vue.computed(() =>
props.date.toLocaleDateString('en-US', { month: 'short' })
);
const holiday = Vue.computed(() => getHoliday(props.date));
const specialDay = Vue.computed(() => getSpecialDay(props.date));
const isHighlighted = Vue.computed(() => highlightedDateStr.value === dateStr.value);
const onEnter = () => { hoveredDateStr.value = dateStr.value; };
const onLeave = () => { hoveredDateStr.value = null; };
const onToggle = () => { toggleColHighlight(props.date); };
return {
rootClass, dayLabelClass, dayNumClass,
weekdayShort, dayNum, monthShort,
holiday, specialDay, isHighlighted, dateStr,
onEnter, onLeave, onToggle
};
},
template: `
<div :class="rootClass"
@mouseenter="onEnter"
@mouseleave="onLeave">
<div class="absolute-top-right q-pa-xs">
<q-icon v-if="holiday" name="celebration" size="10px" color="red-5" class="cursor-help">
<q-tooltip class="bg-red-9 text-white shadow-4 q-pa-sm">Holiday: {{ holiday }}</q-tooltip>
</q-icon>
<q-icon v-else-if="specialDay" name="bookmark" size="10px" color="indigo-4" class="cursor-help">
<q-tooltip class="bg-indigo-9 text-white shadow-4 q-pa-sm">Event: {{ specialDay }}</q-tooltip>
</q-icon>
</div>
<div class="text-[10px] uppercase text-weight-bold" :class="dayLabelClass">{{ weekdayShort }}</div>
<div class="text-subtitle2 text-weight-bold leading-none" :class="dayNumClass">{{ dayNum }}. {{ monthShort }}</div>
<q-btn round flat dense
:icon="isHighlighted ? 'visibility_off' : 'visibility'"
:color="isHighlighted ? 'amber-9' : 'grey-5'"
size="sm"
class="date-header-highlight-btn absolute-bottom-right q-ma-xs"
:class="{ 'date-header-is-active': isHighlighted }"
@click.stop="onToggle">
<q-tooltip>{{ isHighlighted ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
</q-btn>
<div v-if="isHighlighted" class="date-header-active-bar"></div>
</div>
`
};

View File

@@ -0,0 +1,249 @@
/* filter-drawer — modern right side filter panel */
.filter-drawer-root {
background: var(--background-secondary) !important;
border-left: 1px solid var(--border-color) !important;
}
.filter-drawer-header {
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md) var(--spacing-xl) !important;
border-bottom: 1px solid var(--border-color-light) !important;
background: var(--background-primary) !important;
}
.filter-drawer-title {
font-weight: 700 !important;
font-size: 18px !important;
color: var(--primary-dark) !important;
line-height: 1.2 !important;
}
.filter-drawer-subtitle {
font-size: 12px !important;
color: var(--text-tertiary) !important;
font-weight: 500 !important;
line-height: 1.4 !important;
margin-top: var(--spacing-xs) !important;
}
.filter-drawer-close-btn {
color: var(--text-secondary) !important;
border-radius: var(--radius-lg) !important;
transition: all 0.2s ease !important;
}
.filter-drawer-close-btn:hover {
background-color: var(--hover-bg) !important;
color: var(--primary-color) !important;
}
.filter-drawer-section-title {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.1em;
color: var(--text-tertiary);
margin-top: var(--spacing-2xl);
margin-bottom: var(--spacing-md);
text-transform: uppercase;
padding-left: var(--spacing-sm);
border-left: 2px solid var(--primary-color);
}
.filter-drawer-section {
background: var(--background-primary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius-lg) !important;
padding: var(--spacing-md) !important;
margin-bottom: var(--spacing-lg) !important;
box-shadow: var(--shadow-sm) !important;
transition: all 0.2s ease !important;
}
.filter-drawer-section:hover {
box-shadow: var(--shadow-md) !important;
border-color: var(--primary-light) !important;
}
.filter-drawer-toggle {
font-weight: 600 !important;
color: var(--text-primary) !important;
font-size: 13px !important;
}
.filter-drawer-toggle .q-toggle__track {
border-radius: var(--radius-sm) !important;
}
.filter-drawer-input {
border-radius: var(--radius-md) !important;
}
.filter-drawer-input .q-field__control {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-color) !important;
background: var(--background-primary) !important;
}
.filter-drawer-input .q-field__control:hover {
border-color: var(--border-color-dark) !important;
}
.filter-drawer-input .q-field--focused .q-field__control {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 1px var(--primary-color) !important;
}
.filter-drawer-select {
border-radius: var(--radius-md) !important;
}
.filter-drawer-select .q-field__control {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-color) !important;
background: var(--background-primary) !important;
}
.filter-drawer-select .q-field__control:hover {
border-color: var(--border-color-dark) !important;
}
.filter-drawer-checkbox {
border-radius: var(--radius-sm) !important;
}
.filter-drawer-checkbox .q-checkbox__inner {
border-radius: var(--radius-sm) !important;
}
.filter-drawer-clear-btn {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--danger-color) !important;
background-color: rgba(239, 68, 68, 0.1) !important;
color: var(--danger-color) !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 6px 16px !important;
transition: all 0.2s ease !important;
text-transform: none !important;
letter-spacing: 0.01em !important;
}
.filter-drawer-clear-btn:hover {
background-color: rgba(239, 68, 68, 0.2) !important;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.filter-drawer-saved-item {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-color-light) !important;
background: var(--background-primary) !important;
transition: all 0.2s ease !important;
margin-bottom: var(--spacing-xs) !important;
padding: var(--spacing-sm) var(--spacing-md) !important;
}
.filter-drawer-saved-item:hover {
background-color: var(--hover-bg) !important;
border-color: var(--primary-light) !important;
transform: translateX(2px);
}
.filter-drawer-saved-item:active {
background-color: var(--active-bg) !important;
}
.filter-drawer-saved-icon {
color: var(--text-secondary) !important;
transition: color 0.2s ease !important;
}
.filter-drawer-saved-item:hover .filter-drawer-saved-icon {
color: var(--primary-color) !important;
}
.filter-drawer-saved-label {
font-weight: 600 !important;
color: var(--text-primary) !important;
font-size: 13px !important;
}
.filter-drawer-chip {
border-radius: var(--radius-sm) !important;
background: var(--background-tertiary) !important;
border: 1px solid var(--border-color) !important;
font-size: 11px !important;
font-weight: 600 !important;
color: var(--text-secondary) !important;
padding: 2px 8px !important;
margin: var(--spacing-xs) !important;
}
.filter-drawer-chip-remove {
color: var(--text-tertiary) !important;
border-radius: var(--radius-rounded) !important;
width: 14px !important;
height: 14px !important;
min-width: 14px !important;
min-height: 14px !important;
}
.filter-drawer-chip-remove:hover {
background-color: var(--hover-bg) !important;
color: var(--danger-color) !important;
}
/* Date picker styling */
.filter-drawer-date-picker {
border-radius: var(--radius-xl) !important;
overflow: hidden !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-lg) !important;
}
.filter-drawer-date-picker .q-date {
background: var(--background-primary) !important;
color: var(--text-primary) !important;
}
.filter-drawer-date-picker .q-date__navigation {
background: var(--background-secondary) !important;
border-bottom: 1px solid var(--border-color) !important;
}
.filter-drawer-date-picker .q-date__today button {
color: var(--primary-color) !important;
font-weight: 600 !important;
}
/* Scroll area styling */
.filter-drawer-scroll-area {
padding: var(--spacing-xl) !important;
}
/* Mobile optimizations */
@media (max-width: 599px) {
.filter-drawer-root {
width: 280px !important;
}
.filter-drawer-header {
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md) var(--spacing-lg) !important;
}
.filter-drawer-scroll-area {
padding: var(--spacing-lg) !important;
}
.filter-drawer-section-title {
margin-top: var(--spacing-xl) !important;
}
}
/* Animation for section expansion */
.filter-drawer-section-expand-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
max-height: 500px !important;
opacity: 1 !important;
}
.filter-drawer-section-expand-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.filter-drawer-section-expand-enter-from,
.filter-drawer-section-expand-leave-to {
max-height: 0 !important;
opacity: 0 !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
border-width: 0 !important;
}

View File

@@ -0,0 +1,129 @@
/**
* filter-drawer.js
* ==================
* Right filter drawer: saved views, shift availability filter,
* hub / dept / role / skill selects.
*/
import {
filterDrawer,
pickerStartDay,
clearFilters,
applySavedFilter,
filterByAvailability,
filterDate,
proxyFilterDate,
filterShiftTypes,
activeHub,
activeDept,
filterRoles,
filterSkills,
updateFilterDateProxy,
applyFilterDate
} from '../../services/planner-state.js';
import {
SHIFTS,
DEPARTMENTS,
ROLES,
SKILLS,
hubOptions
} from '../../services/data-service.js';
export default {
name: 'FilterDrawer',
setup() {
return {
filterDrawer,
pickerStartDay,
clearFilters,
applySavedFilter,
filterByAvailability,
filterDate,
proxyFilterDate,
filterShiftTypes,
activeHub,
activeDept,
filterRoles,
filterSkills,
updateFilterDateProxy,
applyFilterDate,
shifts: SHIFTS,
depts: DEPARTMENTS,
roles: ROLES,
allSkills: SKILLS,
hubOptions
};
},
template: `
<q-drawer v-model="filterDrawer" side="right" bordered :width="300" class="filter-drawer-root">
<div class="filter-drawer-header">
<div class="row items-center justify-between q-mb-xs">
<div class="filter-drawer-title">Filters</div>
<q-btn flat round dense icon="close" size="sm" class="filter-drawer-close-btn" @click="filterDrawer = false"></q-btn>
</div>
<div class="filter-drawer-subtitle">Refine the agent list by skills and expertise</div>
</div>
<q-scroll-area class="filter-drawer-scroll-area">
<q-btn flat class="filter-drawer-clear-btn q-mb-lg" icon="filter_alt_off" label="Clear All Filters" @click="clearFilters"></q-btn>
<div class="filter-drawer-section-title">Saved Views</div>
<q-list dense class="q-gutter-y-xs">
<q-item clickable v-ripple class="filter-drawer-saved-item" @click="applySavedFilter('all')">
<q-item-section avatar><q-icon name="people_outline" size="xs" class="filter-drawer-saved-icon"></q-icon></q-item-section>
<q-item-section class="filter-drawer-saved-label">All Agents</q-item-section>
</q-item>
<q-item clickable v-ripple class="filter-drawer-saved-item" @click="applySavedFilter('high_potential')">
<q-item-section avatar><q-icon name="star_outline" size="xs" class="filter-drawer-saved-icon" color="warning"></q-icon></q-item-section>
<q-item-section class="filter-drawer-saved-label">High Expertise</q-item-section>
</q-item>
<q-item clickable v-ripple class="filter-drawer-saved-item" @click="applySavedFilter('remote')">
<q-item-section avatar><q-icon name="house_siding" size="xs" class="filter-drawer-saved-icon" color="info"></q-icon></q-item-section>
<q-item-section class="filter-drawer-saved-label">Remote Support</q-item-section>
</q-item>
</q-list>
<div class="filter-drawer-section-title q-mt-lg">Shift Availability</div>
<div class="filter-drawer-section">
<q-toggle v-model="filterByAvailability" label="Filter by Active Shift" dense class="filter-drawer-toggle" color="positive"></q-toggle>
<transition appear enter-active-class="filter-drawer-section-expand-enter-active" leave-active-class="filter-drawer-section-expand-leave-active">
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-sm">
<q-input v-model="filterDate" filled dense label="Target Date" class="filter-drawer-input">
<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="filter-drawer-date-picker">
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal :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="primary" 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="filter-drawer-checkbox col-12 text-caption"></q-checkbox>
</div>
</div>
</transition>
</div>
<div class="filter-drawer-section-title">Hub Location</div>
<q-select v-model="activeHub" :options="hubOptions" emit-value map-options outlined dense class="filter-drawer-select" bg-color="white"></q-select>
<div class="filter-drawer-section-title">Primary Division</div>
<q-select v-model="activeDept" :options="['All', ...depts]" outlined dense class="filter-drawer-select" bg-color="white"></q-select>
<div class="filter-drawer-section-title">Roles</div>
<q-select v-model="filterRoles" :options="roles" multiple outlined dense class="filter-drawer-select" use-chips bg-color="white" placeholder="Filter by role..."></q-select>
<div class="filter-drawer-section-title">Skills & Expertise</div>
<q-select v-model="filterSkills" :options="allSkills" multiple outlined dense class="filter-drawer-select" use-chips bg-color="white" placeholder="Select expertise..."></q-select>
</q-scroll-area>
</q-drawer>
`
};

View File

@@ -0,0 +1,186 @@
/* grid-cell — individual shift cell with modern UI/UX */
.grid-cell-root {
width: var(--cell-width);
min-width: var(--cell-width);
height: 52px; /* Reduced from 58px for denser layout */
border-right: 1px solid var(--border-color-light);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--background-primary);
}
.grid-cell-root:hover:not(.cursor-not-allowed) {
background-color: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1);
z-index: 2;
}
.grid-cell-compact {
height: 38px; /* Reduced from 42px for denser layout */
}
.grid-cell-shift-badge {
font-size: 9px;
font-weight: 800;
padding: 2px 6px;
border-radius: var(--radius-sm);
width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
border: 1px solid rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
/* Shift type color variations */
.grid-cell-shift-badge.shift-am {
background-color: rgba(14, 165, 233, 0.15);
color: #0c4a6e;
border-color: rgba(14, 165, 233, 0.3);
}
.grid-cell-shift-badge.shift-pm {
background-color: rgba(168, 85, 247, 0.15);
color: #5b21b6;
border-color: rgba(168, 85, 247, 0.3);
}
.grid-cell-shift-badge.shift-night {
background-color: rgba(79, 70, 229, 0.15);
color: #3730a3;
border-color: rgba(79, 70, 229, 0.3);
}
.grid-cell-shift-badge.shift-full {
background-color: rgba(16, 185, 129, 0.15);
color: #065f46;
border-color: rgba(16, 185, 129, 0.3);
}
.grid-cell-locked-overlay {
position: absolute;
inset: 0;
background: rgba(241, 245, 249, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
backdrop-filter: blur(1px);
border-radius: var(--radius-sm);
}
.grid-cell-tooltip {
padding: var(--spacing-md) var(--spacing-lg);
max-width: 240px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
background: var(--background-primary);
border-radius: var(--radius-lg);
font-size: 12px;
line-height: 1.5;
}
/* Weekend + highlight helpers (applied via dynamic class) */
.grid-cell-bg-weekend {
background-color: var(--background-tertiary) !important;
}
.grid-cell-bg-reading-mode {
background-color: rgba(255, 249, 196, 0.8) !important;
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.3);
}
.grid-cell-bg-reading-mode-intersection {
background-color: rgba(255, 241, 118, 0.9) !important;
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.5);
}
.grid-cell-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;
}
/* Selection state */
.grid-cell-selected {
background-color: rgba(79, 70, 229, 0.1) !important;
box-shadow: inset 0 0 0 2px var(--primary-color);
}
/* Availability indicators */
.grid-cell-available {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--success-color);
box-shadow: 0 0 0 2px var(--background-primary);
z-index: 3;
}
.grid-cell-unavailable {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--danger-color);
box-shadow: 0 0 0 2px var(--background-primary);
z-index: 3;
}
.grid-cell-partial {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--warning-color);
box-shadow: 0 0 0 2px var(--background-primary);
z-index: 3;
}
/* Empty state */
.grid-cell-empty {
color: var(--text-tertiary);
font-size: 10px;
font-weight: 500;
opacity: 0.6;
}
/* Conflict state */
.grid-cell-conflict {
background-color: rgba(239, 68, 68, 0.08) !important;
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.3);
animation: pulse-conflict 2s ease-in-out infinite;
}
@keyframes pulse-conflict {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Hover tooltip enhancement */
.grid-cell-root:hover .grid-cell-tooltip-target {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.grid-cell-tooltip-target {
opacity: 0;
visibility: hidden;
transform: translateY(5px);
transition: all 0.2s ease;
}

View File

@@ -0,0 +1,112 @@
/**
* grid-cell.js
* =============
* A single shift cell inside an agent row.
* Props: agentId, date
*/
import {
formatDateForId,
isWeekend,
weekendsAreWorkingDays,
isCompact,
crosshairActive,
hoveredDateStr,
highlightedRowId,
highlightedDateStr
} from '../../services/planner-state.js';
import {
getAssignment,
hasComment,
hasNote,
getCommentText,
getNoteText
} from '../../services/data-service.js';
import { isCellLocked } from '../../services/socket-service.js';
export default {
name: 'GridCell',
props: {
agentId: { type: Number, required: true },
date: { type: Date, required: true }
},
emits: ['open-assignment'],
setup(props, { emit }) {
const cellClass = Vue.computed(() => {
const agentId = props.agentId;
const date = props.date;
const isRow = highlightedRowId.value === agentId;
const isCol = highlightedDateStr.value === formatDateForId(date);
const isHoverCol = crosshairActive.value && hoveredDateStr.value === formatDateForId(date);
const isWknd = isWeekend(date) && !weekendsAreWorkingDays.value;
const locked = isCellLocked(agentId, date);
const classes = ['grid-cell-root'];
if (isCompact.value) classes.push('grid-cell-compact');
if (isWknd) classes.push('grid-cell-bg-weekend');
if (isWknd && !weekendsAreWorkingDays.value) classes.push('cursor-not-allowed');
else if (locked) classes.push('cursor-not-allowed');
else classes.push('cursor-pointer');
if (isRow && isCol) classes.push('grid-cell-bg-reading-mode-intersection');
else if (isRow || isCol) classes.push('grid-cell-bg-reading-mode');
if (isHoverCol) classes.push('grid-cell-col-hovered');
return classes.join(' ');
});
const assignment = Vue.computed(() => getAssignment(props.agentId, props.date));
const locked = Vue.computed(() => isCellLocked(props.agentId, props.date));
const showShift = Vue.computed(() => weekendsAreWorkingDays.value || !isWeekend(props.date));
const comment = Vue.computed(() => hasComment(props.agentId, props.date));
const note = Vue.computed(() => hasNote(props.agentId, props.date));
const commentTxt = Vue.computed(() => getCommentText(props.agentId, props.date));
const noteTxt = Vue.computed(() => getNoteText(props.agentId, props.date));
const onEnter = () => { hoveredDateStr.value = formatDateForId(props.date); };
const onLeave = () => { hoveredDateStr.value = null; };
const onClick = () => { emit('open-assignment', props.date); };
return {
cellClass, assignment, locked, showShift,
comment, note, commentTxt, noteTxt,
onEnter, onLeave, onClick
};
},
template: `
<div :class="cellClass"
@mouseenter="onEnter"
@mouseleave="onLeave"
@click="onClick">
<template v-if="showShift">
<div v-if="assignment"
class="grid-cell-shift-badge shadow-1"
:class="assignment.badgeClass">{{ assignment.label }}</div>
</template>
<div v-if="locked" class="grid-cell-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="comment" 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 grid-cell-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">{{ commentTxt }}</div>
</q-tooltip>
</q-icon>
<q-icon v-if="note" name="info" size="8px" color="orange-4" class="cursor-help opacity-70">
<q-tooltip class="bg-white text-grey-9 border shadow-4 grid-cell-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">{{ noteTxt }}</div>
</q-tooltip>
</q-icon>
</div>
</div>
`
};

View File

@@ -0,0 +1,284 @@
/* online-users — modern avatar stack with unified color scheme */
.online-users-stack {
display: flex;
align-items: center;
flex-wrap: nowrap;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--background-tertiary);
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
}
.online-users-stack:hover {
box-shadow: var(--shadow-md);
border-color: var(--primary-light);
background: var(--background-primary);
}
.online-users-avatar {
margin-left: -10px;
border: 2px solid var(--background-primary);
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
box-shadow: var(--shadow-sm);
border-radius: var(--radius-rounded) !important;
}
.online-users-avatar:first-child {
margin-left: 0;
}
.online-users-avatar:hover {
transform: translateY(-3px) scale(1.05);
z-index: 20;
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
/* Avatar status indicators */
.online-users-avatar.online {
position: relative;
}
.online-users-avatar.online::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
background-color: var(--success-color);
border-radius: 50%;
border: 2px solid var(--background-primary);
z-index: 10;
animation: online-pulse 2s ease-in-out infinite;
}
.online-users-avatar.away {
position: relative;
opacity: 0.8;
}
.online-users-avatar.away::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
background-color: var(--warning-color);
border-radius: 50%;
border: 2px solid var(--background-primary);
z-index: 10;
}
.online-users-avatar.busy {
position: relative;
opacity: 0.7;
}
.online-users-avatar.busy::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
background-color: var(--danger-color);
border-radius: 50%;
border: 2px solid var(--background-primary);
z-index: 10;
}
/* enter / leave transitions */
.online-avatar-enter-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.online-avatar-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.online-avatar-enter-from {
opacity: 0;
transform: scale(0.4) translateY(8px) rotate(-10deg);
}
.online-avatar-leave-to {
opacity: 0;
transform: scale(0.4) translateY(-8px) rotate(10deg);
}
/* +N overflow circle */
.online-users-overflow {
width: 32px;
height: 32px;
margin-left: -10px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-light), var(--info-color));
color: var(--primary-dark);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
z-index: 5;
border: 2px solid var(--background-primary);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.online-users-overflow:hover {
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
color: var(--text-white);
transform: translateY(-2px) scale(1.1);
box-shadow: var(--shadow-md);
}
/* count badge transition */
.online-count-enter-active,
.online-count-leave-active {
transition: all 0.3s ease;
}
.online-count-enter-from,
.online-count-leave-to {
opacity: 0;
transform: scale(0.6);
}
/* profile tooltip card */
.online-users-profile-card {
min-width: 280px;
padding: var(--spacing-lg);
background: var(--background-primary);
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
}
.online-users-profile-header {
display: flex;
align-items: center;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color-light);
}
.online-users-profile-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-rounded);
border: 3px solid var(--primary-light);
margin-right: var(--spacing-md);
}
.online-users-profile-name {
font-weight: 700;
font-size: 16px;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.online-users-profile-role {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
background: var(--background-tertiary);
padding: 2px 8px;
border-radius: var(--radius-sm);
display: inline-block;
}
.online-users-profile-status {
display: flex;
align-items: center;
font-size: 12px;
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.online-users-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success-color);
display: inline-block;
margin-right: 6px;
animation: online-pulse 2s ease-in-out infinite;
}
.online-users-profile-details {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.online-users-profile-details strong {
color: var(--text-primary);
font-weight: 600;
}
/* Online users count badge */
.online-users-count {
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
color: var(--text-white);
font-size: 11px;
font-weight: 800;
padding: 2px 8px;
border-radius: var(--radius-rounded);
margin-left: var(--spacing-sm);
box-shadow: var(--shadow-sm);
border: 2px solid var(--background-primary);
}
/* Mobile optimizations */
@media (max-width: 599px) {
.online-users-stack {
padding: var(--spacing-xs);
border-radius: var(--radius-lg);
}
.online-users-avatar {
width: 28px !important;
height: 28px !important;
margin-left: -8px;
}
.online-users-overflow {
width: 28px;
height: 28px;
margin-left: -8px;
font-size: 9px;
}
.online-users-count {
font-size: 10px;
padding: 1px 6px;
}
}
@keyframes online-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(0.9);
}
}
@keyframes avatar-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.online-users-avatar:hover {
animation: avatar-float 1.5s ease-in-out infinite;
}
/* Group hover effect */
.online-users-stack:hover .online-users-avatar {
transform: translateX(2px);
}
.online-users-stack:hover .online-users-avatar:hover {
transform: translateY(-3px) scale(1.05) translateX(0);
}

View File

@@ -0,0 +1,129 @@
/**
* online-users.js
* ================
* Self-contained component showing online collaborators.
* Desktop: avatar stack with animated enter/leave + overflow "+N" circle.
* Mobile: icon button with badge and dropdown list.
*
* Reacts to WSS-driven changes in onlineUsers (from planner-state).
*/
import {
onlineUsers,
visibleOnlineUsers,
remainingOnlineCount
} from '../../services/planner-state.js';
export default {
name: 'OnlineUsers',
setup() {
return {
onlineUsers,
visibleOnlineUsers,
remainingOnlineCount
};
},
template: `
<!-- ============ Desktop avatar stack ============ -->
<div class="row items-center gt-sm q-mr-sm">
<div class="online-users-stack">
<transition-group name="online-avatar">
<q-avatar
v-for="user in visibleOnlineUsers"
:key="user.id"
size="32px"
class="online-users-avatar"
>
<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="online-users-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-weight-medium" style="color: #10b981">
<span class="online-users-status-dot"></span> Online Now
</div>
</div>
</div>
</q-tooltip>
</q-avatar>
</transition-group>
<!-- +N overflow circle (only when more users than visible slots) -->
<transition name="online-count">
<div
v-if="remainingOnlineCount > 0"
class="online-users-overflow"
>
+{{ remainingOnlineCount }}
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
<q-list dense style="min-width: 260px">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">
Active Collaborators ({{ onlineUsers.length }})
</q-item-label>
<q-separator></q-separator>
<q-scroll-area style="height: 260px;">
<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-section side>
<span class="online-users-status-dot"></span>
</q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</div>
</transition>
</div>
</div>
<!-- ============ Mobile button ============ -->
<div class="lt-md">
<q-btn round flat dense color="grey-6" icon="group">
<q-badge v-if="onlineUsers.length > 0" color="teal" 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 ({{ onlineUsers.length }})
</q-item-label>
<q-separator></q-separator>
<div v-if="onlineUsers.length === 0" class="q-pa-md text-center text-grey-5 text-caption">
No collaborators online
</div>
<q-scroll-area v-else 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-section side>
<span class="online-users-status-dot"></span>
</q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</q-btn>
</div>
`
};

View File

@@ -0,0 +1,270 @@
/* planner-grid — main scrollable grid area with modern UI/UX */
.planner-grid-viewport {
height: calc(100vh - 64px) !important;
overflow: auto !important;
background: var(--background-primary);
position: relative;
display: block;
border-top: 1px solid var(--border-color);
box-shadow: var(--shadow-sm) inset 0 1px 0 0 var(--border-color-light);
}
.planner-grid-content {
display: inline-block;
min-width: fit-content;
vertical-align: top;
}
.planner-grid-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--border-color-light);
}
.planner-grid-left-col {
width: var(--left-col-width);
min-width: var(--left-col-width);
position: sticky;
left: 0;
z-index: 20;
background: var(--background-primary);
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 var(--spacing-xl);
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
transition: background-color 0.2s ease;
}
@media (max-width: 599px) {
.planner-grid-left-col {
padding: 0 var(--spacing-md) !important;
font-size: 12px;
}
.planner-grid-agent-role {
display: none;
}
}
.planner-grid-cells-area {
display: flex;
flex-direction: row;
}
/* Sticky header rows - denser heights */
.planner-grid-row-eod {
position: sticky;
top: 0;
z-index: 40;
height: var(--h-eod);
background: rgba(239, 68, 68, 0.04);
border-bottom: 1px solid rgba(239, 68, 68, 0.1);
}
.planner-grid-row-eod .planner-grid-left-col {
z-index: 60;
background: rgba(239, 68, 68, 0.04);
color: var(--danger-color);
font-weight: 600;
font-size: 12px;
border-bottom: 1px solid rgba(239, 68, 68, 0.1);
}
.planner-grid-row-status {
position: sticky;
top: var(--h-eod);
z-index: 39;
height: var(--h-status);
background: var(--background-primary);
border-bottom: 1px solid var(--border-color-light);
}
.planner-grid-row-status .planner-grid-left-col {
z-index: 59;
background: var(--background-primary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.planner-grid-row-dates {
position: sticky;
top: calc(var(--h-eod) + var(--h-status));
z-index: 38;
height: var(--h-dates);
background: var(--background-secondary);
border-bottom: 1px solid var(--border-color);
}
.planner-grid-row-dates .planner-grid-left-col {
z-index: 58;
background: var(--background-secondary);
padding-right: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
color: var(--text-primary);
}
/* Group headers - unified color scheme */
.planner-grid-header-l1 {
background: var(--primary-light);
font-size: 11px;
font-weight: 900;
color: var(--primary-dark);
letter-spacing: 0.05em;
height: 32px; /* Reduced from 36px for denser layout */
border-top: 1px solid rgba(79, 70, 229, 0.2);
border-bottom: 1px solid rgba(79, 70, 229, 0.1);
text-transform: uppercase;
}
.planner-grid-header-l1 .planner-grid-left-col {
background: var(--primary-light);
color: var(--primary-dark);
}
.planner-grid-header-l2 {
background: var(--background-tertiary);
font-size: 10px;
font-weight: 800;
color: var(--text-secondary);
letter-spacing: 0.025em;
height: 28px; /* Reduced from 32px for denser layout */
border-top: 1px solid var(--border-color-light);
border-bottom: 1px solid var(--border-color-light);
}
.planner-grid-header-l2 .planner-grid-left-col {
background: var(--background-tertiary);
color: var(--text-secondary);
padding-left: var(--spacing-2xl);
}
/* Agent row - compact mode */
.planner-grid-compact {
height: 38px; /* Reduced from 42px for denser layout */
}
/* Row highlight (persistent reading helper) */
.planner-grid-row-highlighted .planner-grid-left-col {
background-color: rgba(255, 249, 196, 0.9) !important;
box-shadow: inset 2px 0 0 var(--warning-color);
}
/* Agent row hover triggers for highlight button */
.planner-grid-agent-hover .planner-grid-highlight-btn {
opacity: 0;
transition: opacity 0.2s ease;
}
.planner-grid-agent-hover:hover .planner-grid-highlight-btn,
.planner-grid-highlight-btn.planner-grid-is-active {
opacity: 1;
}
/* Crosshair row highlight on hover */
.planner-grid-row-item.planner-grid-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-grid-row-item.planner-grid-crosshair-enabled:hover .planner-grid-left-col {
background-color: rgba(79, 70, 229, 0.1) !important;
border-top: 1px solid var(--highlight-border);
border-bottom: 1px solid var(--highlight-border);
color: var(--primary-dark);
font-weight: 600;
}
/* EOD / availability cell (inside header rows) */
.planner-grid-eod-cell {
width: var(--cell-width);
min-width: var(--cell-width);
border-right: 1px solid var(--border-color-light);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
/* Search + controls container */
.planner-grid-search-container {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
}
/* Filter button */
.planner-grid-filter-btn-active {
background-color: var(--primary-light) !important;
border: 1px solid rgba(79, 70, 229, 0.3) !important;
color: var(--primary-dark) !important;
font-weight: 600;
}
.planner-grid-filter-status-dot {
width: 6px;
height: 6px;
background-color: var(--warning-color);
border-radius: 50%;
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
box-shadow: 0 0 0 2px var(--primary-light);
z-index: 10;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Dense date card for date picker */
.planner-grid-dense-date-card {
width: 280px;
border-radius: var(--radius-xl) !important;
overflow: hidden;
}
.planner-grid-dense-date-card .q-date {
width: 100%;
min-height: unset;
}
/* Enhanced row hover effects */
.planner-grid-row-item:not(.planner-grid-header-l1):not(.planner-grid-header-l2):not(.planner-grid-row-eod):not(.planner-grid-row-status):not(.planner-grid-row-dates):hover {
background-color: var(--hover-bg);
}
/* Compact spacing utilities */
.planner-grid-compact-spacing {
padding-top: var(--spacing-xs) !important;
padding-bottom: var(--spacing-xs) !important;
}
/* Modern border radius for cells */
.planner-grid-left-col,
.planner-grid-eod-cell {
border-radius: 0; /* Straight edges for grid consistency */
}
/* Weekend styling */
.planner-grid-weekend {
background-color: var(--background-tertiary) !important;
}
/* Status indicators */
.planner-grid-status-available {
background-color: rgba(16, 185, 129, 0.1) !important;
border-left: 2px solid var(--success-color) !important;
}
.planner-grid-status-unavailable {
background-color: rgba(239, 68, 68, 0.1) !important;
border-left: 2px solid var(--danger-color) !important;
}
.planner-grid-status-partial {
background-color: rgba(245, 158, 11, 0.1) !important;
border-left: 2px solid var(--warning-color) !important;
}

View File

@@ -0,0 +1,289 @@
/**
* planner-grid.js
* =================
* Main grid area: EOD targets row, availability row, date header row,
* loading skeleton, QVirtualScroll with group headers + agent rows.
* Uses child components: DateHeader, GridCell.
*/
const { ref, nextTick } = Vue;
import {
dates,
gridStyles,
showEodTargets,
showAvailability,
crosshairActive,
hoveredDateStr,
formatDateForId,
isCompact,
highlightedRowId,
highlightedDateStr,
toggleRowHighlight,
search,
filterDrawer,
isFilterActive,
activeFilterCount,
dateMenu,
proxyDate,
syncProxyDate,
pickerStartDay,
startDate,
getStartOfWeek,
rightDrawer,
editMode,
selectedAgent,
selectedDate,
pendingShift,
weekendsAreWorkingDays,
isWeekend
} from '../../services/planner-state.js';
import {
loading,
flattenedList
} from '../../services/data-service.js';
import { isCellLocked } from '../../services/socket-service.js';
import DateHeader from '../date-header/date-header.js';
import GridCell from '../grid-cell/grid-cell.js';
export default {
name: 'PlannerGrid',
components: { DateHeader, GridCell },
setup() {
const viewport = ref(null);
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 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;
};
return {
viewport,
dates,
gridStyles,
showEodTargets,
showAvailability,
crosshairActive,
hoveredDateStr,
formatDateForId,
isCompact,
highlightedRowId,
highlightedDateStr,
toggleRowHighlight,
search,
filterDrawer,
isFilterActive,
activeFilterCount,
dateMenu,
proxyDate,
syncProxyDate,
pickerStartDay,
loading,
flattenedList,
applyDateSelection,
openAssignment,
openProfile
};
},
template: `
<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 -->
<div id="viewport-target"
class="planner-grid-viewport"
ref="viewport"
:style="gridStyles">
<div class="planner-grid-content">
<!-- EOD Targets Row -->
<div v-if="showEodTargets" class="planner-grid-row planner-grid-row-eod">
<div class="planner-grid-left-col text-overline text-weight-bold">EOD Targets</div>
<div class="planner-grid-cells-area">
<div v-for="(d, i) in dates" :key="'e'+i"
class="planner-grid-eod-cell"
:class="{'grid-cell-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>
<!-- Availability Row -->
<div v-if="showAvailability" class="planner-grid-row planner-grid-row-status">
<div class="planner-grid-left-col text-overline text-grey-5">Availability</div>
<div class="planner-grid-cells-area">
<div v-for="(d, i) in dates" :key="'s'+i"
class="planner-grid-eod-cell"
:class="{'grid-cell-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>
<!-- Date Header Row -->
<div class="planner-grid-row planner-grid-row-dates">
<div class="planner-grid-left-col">
<div class="planner-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 ? 'planner-grid-filter-btn-active' : ''"
size="sm"
@click="filterDrawer = !filterDrawer">
<div v-if="isFilterActive" class="planner-grid-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="planner-grid-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="planner-grid-cells-area">
<date-header v-for="(date, i) in dates" :key="'d'+i" :date="date"></date-header>
</div>
</div>
<!-- LOADING SKELETON -->
<div v-if="loading" class="q-pa-none">
<div v-for="n in 18" :key="n" class="planner-grid-row">
<div class="planner-grid-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="planner-grid-cells-area">
<div v-for="c in 8*7" :key="c" class="planner-grid-eod-cell" style="width:var(--cell-width);min-width:var(--cell-width);height:58px">
<!--<q-skeleton v-if="Math.random() > 0.6" type="rect" height="20px" width="80%"
class="rounded-borders" style="opacity: 0.3" />-->
<q-skeleton type="rect" height="20px" width="70%"
class="rounded-borders" />
</div>
</div>
</div>
</div>
<!-- VIRTUAL SCROLL -->
<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 -->
<div v-if="item.type === 'header-l1'" :key="item.id"
class="planner-grid-row planner-grid-header-l1">
<div class="planner-grid-left-col">{{ item.label }}</div>
<div class="planner-grid-cells-area">
<div class="planner-grid-eod-cell" style="flex: 1; height: 36px; border-right: none"></div>
</div>
</div>
<!-- Level 2 Header -->
<div v-else-if="item.type === 'header-l2'" :key="item.id"
class="planner-grid-row planner-grid-header-l2">
<div class="planner-grid-left-col">{{ item.label }}</div>
<div class="planner-grid-cells-area">
<div class="planner-grid-eod-cell" style="flex: 1; height: 32px; border-right: none"></div>
</div>
</div>
<!-- Agent Row -->
<div v-else :key="item.id"
class="planner-grid-row planner-grid-row-item"
:class="{
'planner-grid-row-highlighted': highlightedRowId === item.data.id,
'planner-grid-crosshair-enabled': crosshairActive
}">
<div class="planner-grid-left-col border-b cursor-pointer planner-grid-agent-hover relative-position"
:class="[isCompact ? 'planner-grid-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 planner-grid-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="planner-grid-highlight-btn"
:class="{ 'planner-grid-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="planner-grid-cells-area">
<grid-cell v-for="(date, i) in dates"
:key="'c'+item.data.id+i"
:agent-id="item.data.id"
:date="date"
@open-assignment="openAssignment(item.data, $event)">
</grid-cell>
</div>
</div>
</template>
</q-virtual-scroll>
</div>
</div>
</q-page>
`
};

View File

@@ -0,0 +1,236 @@
/* workspace-drawer — modern left settings panel */
.workspace-drawer-root {
background: var(--background-secondary) !important;
border-right: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-md) !important;
}
.workspace-drawer-header {
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md) var(--spacing-xl) !important;
border-bottom: 1px solid var(--border-color-light) !important;
background: var(--background-primary) !important;
}
.workspace-drawer-title {
font-weight: 700 !important;
font-size: 18px !important;
color: var(--primary-dark) !important;
line-height: 1.2 !important;
}
.workspace-drawer-subtitle {
font-size: 12px !important;
color: var(--text-tertiary) !important;
font-weight: 500 !important;
line-height: 1.4 !important;
margin-top: var(--spacing-xs) !important;
}
.workspace-drawer-section {
padding: var(--spacing-lg) var(--spacing-xl) !important;
border-bottom: 1px solid var(--border-color-light) !important;
transition: background-color 0.2s ease !important;
}
.workspace-drawer-section:hover {
background-color: var(--hover-bg) !important;
}
.workspace-drawer-section-title {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.1em;
color: var(--text-tertiary);
margin-bottom: var(--spacing-md);
text-transform: uppercase;
padding-left: var(--spacing-sm);
border-left: 2px solid var(--primary-color);
}
.workspace-drawer-select {
border-radius: var(--radius-md) !important;
}
.workspace-drawer-select .q-field__control {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-color) !important;
background: var(--background-primary) !important;
}
.workspace-drawer-select .q-field__control:hover {
border-color: var(--border-color-dark) !important;
}
.workspace-drawer-select .q-field--focused .q-field__control {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 1px var(--primary-color) !important;
}
.workspace-drawer-toggle-item {
padding: var(--spacing-sm) 0 !important;
border-radius: var(--radius-md) !important;
transition: background-color 0.2s ease !important;
}
.workspace-drawer-toggle-item:hover {
background-color: var(--hover-bg) !important;
}
.workspace-drawer-toggle-label {
font-weight: 600 !important;
color: var(--text-primary) !important;
font-size: 13px !important;
}
.workspace-drawer-toggle-caption {
color: var(--text-tertiary) !important;
font-size: 11px !important;
line-height: 1.4 !important;
}
.workspace-drawer-toggle .q-toggle__track {
border-radius: var(--radius-sm) !important;
}
.workspace-drawer-toggle .q-toggle__thumb {
border-radius: var(--radius-rounded) !important;
}
.workspace-drawer-dev-btn {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--info-color) !important;
background-color: rgba(59, 130, 246, 0.1) !important;
color: var(--info-color) !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 8px 16px !important;
transition: all 0.2s ease !important;
text-transform: none !important;
letter-spacing: 0.01em !important;
}
.workspace-drawer-dev-btn:hover {
background-color: rgba(59, 130, 246, 0.2) !important;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.workspace-drawer-reset-btn {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--primary-color) !important;
background-color: rgba(79, 70, 229, 0.1) !important;
color: var(--primary-color) !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 8px 16px !important;
transition: all 0.2s ease !important;
text-transform: none !important;
letter-spacing: 0.01em !important;
}
.workspace-drawer-reset-btn:hover {
background-color: rgba(79, 70, 229, 0.2) !important;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.workspace-drawer-separator {
background-color: var(--border-color-light) !important;
margin: var(--spacing-lg) 0 !important;
height: 1px !important;
}
.workspace-drawer-icon {
color: var(--text-secondary) !important;
transition: color 0.2s ease !important;
}
.workspace-drawer-toggle-item:hover .workspace-drawer-icon {
color: var(--primary-color) !important;
}
/* Color-coded toggles */
.workspace-drawer-toggle[data-color="indigo"] .q-toggle__inner {
color: var(--primary-color) !important;
}
.workspace-drawer-toggle[data-color="pink"] .q-toggle__inner {
color: var(--danger-color) !important;
}
.workspace-drawer-toggle[data-color="orange"] .q-toggle__inner {
color: var(--warning-color) !important;
}
.workspace-drawer-toggle[data-color="green"] .q-toggle__inner {
color: var(--success-color) !important;
}
/* Tooltip styling */
.workspace-drawer-tooltip {
background: var(--background-primary) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-md) !important;
font-size: 12px !important;
padding: var(--spacing-sm) var(--spacing-md) !important;
}
/* Scroll area styling */
.workspace-drawer-scroll-area {
padding: 0 !important;
}
/* Mobile optimizations */
@media (max-width: 599px) {
.workspace-drawer-root {
width: 280px !important;
}
.workspace-drawer-header {
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md) var(--spacing-lg) !important;
}
.workspace-drawer-section {
padding: var(--spacing-md) var(--spacing-lg) !important;
}
.workspace-drawer-title {
font-size: 16px !important;
}
.workspace-drawer-subtitle {
font-size: 11px !important;
}
}
/* Animation for section hover */
@keyworkspace-drawer-section-hover {
from {
background-color: var(--background-secondary);
}
to {
background-color: var(--hover-bg);
}
}
.workspace-drawer-section:hover {
animation: workspace-drawer-section-hover 0.3s ease forwards;
}
/* Status indicators */
.workspace-drawer-status-active {
position: relative;
}
.workspace-drawer-status-active::after {
content: '';
position: absolute;
top: 50%;
right: var(--spacing-md);
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--success-color);
animation: workspace-pulse 2s ease-in-out infinite;
}
@keyframes workspace-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}

View File

@@ -0,0 +1,122 @@
/**
* workspace-drawer.js
* ====================
* Left drawer: timeline range, date-picker start, dev tools,
* grid visibility toggles, compact/weekend switches.
*/
const { nextTick } = Vue;
import {
leftDrawer,
viewScope,
pickerStartDay,
crosshairActive,
showEodTargets,
showAvailability,
isCompact,
weekendsAreWorkingDays,
startDate,
getStartOfWeek,
rightDrawer,
editMode,
selectedDate,
pendingShift
} from '../../services/planner-state.js';
import { simulateWssLock } from '../../services/socket-service.js';
const { useQuasar } = Quasar;
export default {
name: 'WorkspaceDrawer',
setup() {
const $q = useQuasar();
const doSimulate = () => simulateWssLock($q);
const resetToToday = () => {
const today = new Date();
startDate.value = getStartOfWeek(today);
if (rightDrawer.value && editMode.value === 'assignment') {
selectedDate.value = today;
pendingShift.value = null;
}
leftDrawer.value = false;
};
return {
leftDrawer,
viewScope,
pickerStartDay,
crosshairActive,
showEodTargets,
showAvailability,
isCompact,
weekendsAreWorkingDays,
doSimulate,
resetToToday
};
},
template: `
<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>
<!-- Dev Tools -->
<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="doSimulate">
<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>
<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>
`
};

View File

@@ -0,0 +1,62 @@
/**
* main.js
* ========
* Application entry point.
* Registers all components, initializes Quasar, and mounts the app.
*/
const { createApp, onMounted } = Vue;
const { useQuasar } = Quasar;
/* ---- Import components ---- */
import AppHeader from './components/app-header/app-header.js';
import WorkspaceDrawer from './components/workspace-drawer/workspace-drawer.js';
import FilterDrawer from './components/filter-drawer/filter-drawer.js';
import AssignmentDrawer from './components/assignment-drawer/assignment-drawer.js';
import PlannerGrid from './components/planner-grid/planner-grid.js';
/* ---- Import services (for lifecycle) ---- */
import { loadDataFromDatabase } from './services/data-service.js';
import { startOnlineUsersSimulation } from './services/socket-service.js';
/* ---- Root App ---- */
const app = createApp({
components: {
AppHeader,
WorkspaceDrawer,
FilterDrawer,
AssignmentDrawer,
PlannerGrid
},
setup() {
const $q = useQuasar();
onMounted(() => {
loadDataFromDatabase($q);
startOnlineUsersSimulation($q);
});
},
template: `
<q-layout view="hHh Lpr fFf">
<app-header></app-header>
<workspace-drawer></workspace-drawer>
<filter-drawer></filter-drawer>
<assignment-drawer></assignment-drawer>
<q-page-container>
<planner-grid></planner-grid>
</q-page-container>
</q-layout>
`
});
app.use(Quasar, { config: { brand: {
primary: '#4f46e5', /* Indigo-600 */
secondary: '#0ea5e9', /* Sky-500 */
accent: '#8b5cf6', /* Violet-500 */
positive: '#10b981', /* Emerald-500 */
negative: '#ef4444', /* Red-500 */
warning: '#f59e0b', /* Amber-500 */
info: '#3b82f6', /* Blue-500 */
dark: '#1e293b'
} } });
app.mount('#q-app');

View File

@@ -0,0 +1,246 @@
/**
* data-service.js
* ================
* Domain constants, mock-data generation, and reactive data stores
* for agents, assignments, comments, notes, holidays, and special days.
*
* In production, replace generateMockAgents / loadDataFromDatabase
* with real ASP.NET Core API calls.
*/
const { ref, computed, reactive } = Vue;
import {
formatDateForId,
startDate,
weekendsAreWorkingDays,
isWeekend,
filterByAvailability,
filterDate,
filterShiftTypes,
search,
activeDept,
activeHub,
filterRoles,
filterSkills
} from './planner-state.js';
/* ===== CONSTANTS ===== */
export 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)' }
];
export const DEPARTMENTS = ['Support', 'Technical', 'Sales', 'VIP', 'Billing'];
export const ROLES = ['Senior Lead', 'Specialist', 'Agent'];
export const SKILLS = ['English', 'German', 'French', 'Molecular App', 'Hardware', 'Billing Specialist', 'VIP Concierge', 'Technical Training'];
export 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' }
];
export const hubOptions = [
{ label: 'All Hubs', value: 'All' },
...HUBS.map(h => ({ label: h.name, value: h.id }))
];
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.'];
/* ===== REACTIVE DATA STORES ===== */
export const agents = ref([]);
export const loading = ref(false);
export const assignments = reactive({});
export const comments = reactive({});
export const notes = reactive({});
export const holidays = reactive({});
export const specialDays = reactive({});
/* ===== MOCK GENERATOR ===== */
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}`
};
});
};
/* ===== DATABASE LOADER (mock) ===== */
export const loadDataFromDatabase = ($q) => {
loading.value = true;
setTimeout(() => {
const fetchedAgents = generateMockAgents(800);
agents.value = fetchedAgents;
const mockStartDate = new Date(startDate.value);
fetchedAgents.forEach(agent => {
if (!assignments[agent.id]) assignments[agent.id] = {};
if (!comments[agent.id]) comments[agent.id] = {};
if (!notes[agent.id]) notes[agent.id] = {};
for (let i = 0; i < 60; i++) {
const d = new Date(mockStartDate);
d.setDate(d.getDate() + i);
const dStr = formatDateForId(d);
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;
if ((agent.id + i) % 20 === 0) comments[agent.id][dStr] = MOCK_COMMENTS_TEXT[(agent.id + i) % MOCK_COMMENTS_TEXT.length];
if ((agent.id + i) % 25 === 0) notes[agent.id][dStr] = MOCK_NOTES_TEXT[(agent.id + i) % MOCK_NOTES_TEXT.length];
}
});
// Dynamic holidays / special days relative to today
for (const key in holidays) delete holidays[key];
for (const key in specialDays) delete specialDays[key];
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
holidays[formatDateForId(tomorrow)] = 'Regional Holiday';
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);
};
/* ===== ACCESSORS ===== */
export 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;
};
export const getHoliday = (date) => holidays[formatDateForId(date)] || null;
export const getSpecialDay = (date) => specialDays[formatDateForId(date)] || null;
export const hasComment = (agentId, date) => { const d = formatDateForId(date); return comments[agentId] && comments[agentId][d]; };
export const hasNote = (agentId, date) => { const d = formatDateForId(date); return notes[agentId] && notes[agentId][d]; };
export const getCommentText = (agentId, date) => { const d = formatDateForId(date); return comments[agentId] ? comments[agentId][d] : ''; };
export const getNoteText = (agentId, date) => { const d = formatDateForId(date); return notes[agentId] ? notes[agentId][d] : ''; };
/* ===== FILTERING ===== */
export 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;
});
});
/* ===== GROUPING ===== */
export const flattenedList = computed(() => {
const result = [];
const list = [...filteredAgents.value];
const keys = ['hub', 'dept']; // default grouping
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++) {
if (agent[keys[i]] !== 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;
result.push({
type: i === 0 ? 'header-l1' : 'header-l2',
label: getLabel(key, val),
id: `hdr-${key}-${val}-${Math.random()}`
});
}
}
result.push({ type: 'agent', data: agent, id: agent.id });
});
return result;
});
/* ===== ASSIGNMENT ACTIONS ===== */
export const saveAssignment = (selectedAgentRef, selectedDateRef, pendingShiftRef, rightDrawerRef) => {
if (!selectedAgentRef.value || !selectedDateRef.value) return;
const agentId = selectedAgentRef.value.id;
const dateStr = formatDateForId(selectedDateRef.value);
if (!assignments[agentId]) assignments[agentId] = {};
assignments[agentId][dateStr] = pendingShiftRef.value ? pendingShiftRef.value.id : null;
pendingShiftRef.value = null;
rightDrawerRef.value = false;
};

View File

@@ -0,0 +1,178 @@
/**
* planner-state.js
* ================
* Central reactive store consumed by every component.
* No UI — pure state + derived computations.
*/
const { ref, computed, reactive } = Vue;
/* ---- helpers ---- */
export 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('-');
};
export 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;
};
export const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
/* ---- drawer / panel toggles ---- */
export const leftDrawer = ref(false);
export const filterDrawer = ref(false);
export const rightDrawer = ref(false);
export const editMode = ref('assignment');
export const dateMenu = ref(false);
/* ---- grid display settings ---- */
export const isCompact = ref(false);
export const weekendsAreWorkingDays = ref(false);
export const viewScope = ref(8);
export const pickerStartDay = ref(1);
export const showEodTargets = ref(false);
export const showAvailability = ref(false);
/* ---- crosshair / highlighting ---- */
export const crosshairActive = ref(true);
export const hoveredDateStr = ref(null);
export const highlightedRowId = ref(null);
export const highlightedDateStr = ref(null);
export const toggleRowHighlight = (agentId) => {
highlightedRowId.value = highlightedRowId.value === agentId ? null : agentId;
};
export const toggleColHighlight = (date) => {
const str = formatDateForId(date);
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
};
export const clearHighlights = () => {
highlightedRowId.value = null;
highlightedDateStr.value = null;
};
/* ---- search / filter state ---- */
export const search = ref('');
export const activeDept = ref('All');
export const activeHub = ref('All');
export const filterRoles = ref([]);
export const filterSkills = ref([]);
export const filterByAvailability = ref(false);
export const filterDate = ref(formatDateForId(new Date()));
export const proxyFilterDate = ref('');
export const filterShiftTypes = ref([]);
/* ---- date navigation ---- */
export const startDate = ref(getStartOfWeek(new Date()));
export const proxyDate = ref(null);
export 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;
});
/* ---- dynamic CSS-variable overrides ---- */
export const gridStyles = computed(() => ({
'--h-eod': showEodTargets.value ? '42px' : '0px',
'--h-status': showAvailability.value ? '34px' : '0px'
}));
/* ---- selected cell / assignment editing ---- */
export const selectedAgent = ref(null);
export const selectedDate = ref(null);
export const pendingShift = ref(null);
/* ---- filter helpers ---- */
export 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
);
export 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;
});
export const clearFilters = () => {
search.value = '';
activeDept.value = 'All';
activeHub.value = 'All';
filterRoles.value = [];
filterSkills.value = [];
filterByAvailability.value = false;
filterShiftTypes.value = [];
filterDate.value = formatDateForId(new Date());
};
export const applySavedFilter = (key) => {
clearFilters();
if (key === 'high_potential') {
filterSkills.value = ['VIP Concierge', 'Technical Training'];
} else if (key === 'remote') {
filterRoles.value = ['Specialist'];
}
};
/* ---- date picker actions ---- */
export const syncProxyDate = () => {
proxyDate.value = formatDateForId(selectedDate.value || new Date());
};
export const updateFilterDateProxy = () => {
proxyFilterDate.value = filterDate.value;
};
export const applyFilterDate = () => {
filterDate.value = proxyFilterDate.value;
};
/* ---- online users (reactive, driven by socket-service) ---- */
const MAX_VISIBLE_AVATARS = 5;
export const onlineUsers = ref([]);
export const visibleOnlineUsers = computed(() =>
onlineUsers.value.slice(0, MAX_VISIBLE_AVATARS)
);
export const remainingOnlineCount = computed(() =>
Math.max(0, onlineUsers.value.length - MAX_VISIBLE_AVATARS)
);
export const addOnlineUser = (user) => {
if (!onlineUsers.value.find(u => u.id === user.id)) {
onlineUsers.value = [...onlineUsers.value, user];
}
};
export const removeOnlineUser = (userId) => {
onlineUsers.value = onlineUsers.value.filter(u => u.id !== userId);
};

View File

@@ -0,0 +1,204 @@
/**
* socket-service.js
* ==================
* WebSocket simulation for cell-locking and real-time collaboration.
* Simulates:
* - LOCK_CELL — cell locking events
* - USER_ONLINE — a user comes online
* - USER_OFFLINE — a user goes offline
*
* In production, replace the simulation loops with a real
* SignalR / native WebSocket connection.
*/
const { ref } = Vue;
import { formatDateForId, addOnlineUser, removeOnlineUser, onlineUsers } from './planner-state.js';
import { agents } from './data-service.js';
/* ================================================================
USER POOL — potential collaborators who may come and go
================================================================ */
const userPool = [
{ id: 'u01', name: 'Anna Schneider', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=anna01' },
{ id: 'u02', name: 'Markus Weber', role: 'Planner', img: 'https://i.pravatar.cc/150?u=markus02' },
{ id: 'u03', name: 'Lisa Hoffmann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=lisa03' },
{ id: 'u04', name: 'Thomas Müller', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=thomas04' },
{ id: 'u05', name: 'Julia Fischer', role: 'Planner', img: 'https://i.pravatar.cc/150?u=julia05' },
{ id: 'u06', name: 'Stefan Becker', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=stefan06' },
{ id: 'u07', name: 'Katharina Wolf', role: 'Planner', img: 'https://i.pravatar.cc/150?u=kath07' },
{ id: 'u08', name: 'Daniel Braun', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=daniel08' },
{ id: 'u09', name: 'Sandra Koch', role: 'Quality Manager', img: 'https://i.pravatar.cc/150?u=sandra09' },
{ id: 'u10', name: 'Michael Richter', role: 'Planner', img: 'https://i.pravatar.cc/150?u=michael10' },
{ id: 'u11', name: 'Christina Lang', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=chris11' },
{ id: 'u12', name: 'Patrick Schäfer', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=patrick12' },
{ id: 'u13', name: 'Nicole Baumann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=nicole13' },
{ id: 'u14', name: 'Andreas Klein', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=andreas14' },
{ id: 'u15', name: 'Melanie Kraus', role: 'Planner', img: 'https://i.pravatar.cc/150?u=melanie15' },
{ id: 'u16', name: 'Tobias Neumann', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=tobias16' },
{ id: 'u17', name: 'Laura Hartmann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=laura17' },
{ id: 'u18', name: 'Florian Maier', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=florian18' },
{ id: 'u19', name: 'Sabine Engel', role: 'Quality Manager', img: 'https://i.pravatar.cc/150?u=sabine19' },
{ id: 'u20', name: 'Jens Zimmermann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=jens20' },
];
/* ================================================================
CELL LOCKING (existing)
================================================================ */
export const lockedCells = ref(new Set()); // "agentId:YYYY-MM-DD"
/* ================================================================
INCOMING MESSAGE HANDLER
================================================================ */
export const handleWssMessage = (type, payload, $q) => {
/* ---- cell lock ---- */
if (type === 'LOCK_CELL') {
const key = `${payload.agentId}:${payload.date}`;
lockedCells.value.add(key);
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'
});
}
/* ---- user comes online ---- */
if (type === 'USER_ONLINE') {
addOnlineUser(payload);
$q.notify({
message: `${payload.name} joined`,
color: 'white',
textColor: 'grey-8',
position: 'bottom-right',
icon: 'circle',
iconColor: 'teal',
iconSize: '10px',
timeout: 1800,
classes: 'text-caption'
});
}
/* ---- user goes offline ---- */
if (type === 'USER_OFFLINE') {
removeOnlineUser(payload.id);
$q.notify({
message: `${payload.name} left`,
color: 'white',
textColor: 'grey-6',
position: 'bottom-right',
icon: 'circle',
iconColor: 'grey-4',
iconSize: '10px',
timeout: 1800,
classes: 'text-caption'
});
}
};
/* ================================================================
CELL-LOCK SIMULATION (existing)
================================================================ */
export const simulateWssLock = ($q) => {
if (agents.value.length === 0) return;
const randomAgent = agents.value[Math.floor(Math.random() * agents.value.length)];
let targetDate = new Date();
const day = targetDate.getDay();
if (day === 0) targetDate.setDate(targetDate.getDate() + 1);
else if (day === 6) targetDate.setDate(targetDate.getDate() + 2);
const dateStr = formatDateForId(targetDate);
handleWssMessage('LOCK_CELL', { agentId: randomAgent.id, date: dateStr }, $q);
};
/* ---- cell-lock check ---- */
export const isCellLocked = (agentId, date) => {
const key = `${agentId}:${formatDateForId(date)}`;
return lockedCells.value.has(key);
};
/* ================================================================
ONLINE-USERS SIMULATION
Continuously adds/removes users at random intervals (310 s).
Starts with an initial batch of 35 users so the UI isn't empty.
================================================================ */
let onlineSimTimer = null;
/** Random int between min and max (inclusive). */
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
/** Get users from the pool that are NOT currently online. */
const offlinePoolUsers = () =>
userPool.filter(u => !onlineUsers.value.find(o => o.id === u.id));
/** Pick a random element from an array. */
const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];
/** Schedule the next online-users event after a random delay. */
const scheduleNext = ($q) => {
const delay = randInt(3000, 10000);
onlineSimTimer = setTimeout(() => simulateOnlineEvent($q), delay);
};
/** One simulation tick: either bring someone online or take someone offline. */
const simulateOnlineEvent = ($q) => {
const currentCount = onlineUsers.value.length;
const offline = offlinePoolUsers();
// Decide whether to add or remove.
// Bias towards adding when few users are online, towards removing when many.
let shouldAdd;
if (currentCount === 0) shouldAdd = true;
else if (offline.length === 0) shouldAdd = false;
else if (currentCount <= 2) shouldAdd = Math.random() < 0.85;
else if (currentCount >= userPool.length - 2) shouldAdd = Math.random() < 0.15;
else shouldAdd = Math.random() < 0.55;
if (shouldAdd && offline.length > 0) {
const user = pickRandom(offline);
handleWssMessage('USER_ONLINE', user, $q);
} else if (currentCount > 0) {
const user = pickRandom(onlineUsers.value);
handleWssMessage('USER_OFFLINE', user, $q);
}
// Schedule next event
scheduleNext($q);
};
/**
* Start the online-users simulation.
* Seeds 35 users immediately, then continues at random intervals.
*/
export const startOnlineUsersSimulation = ($q) => {
// Stop any existing simulation
stopOnlineUsersSimulation();
// Seed initial users (35) without notifications
const shuffled = [...userPool].sort(() => Math.random() - 0.5);
const seedCount = randInt(3, 5);
for (let i = 0; i < seedCount; i++) {
addOnlineUser(shuffled[i]);
}
// Start the continuous loop
scheduleNext($q);
};
/**
* Stop the online-users simulation and clear all online users.
*/
export const stopOnlineUsersSimulation = () => {
if (onlineSimTimer) {
clearTimeout(onlineSimTimer);
onlineSimTimer = null;
}
};

View File

@@ -0,0 +1,155 @@
/* =============================================
GLOBAL STYLES — shared variables, resets, scrollbars
Modern UI/UX with unified color scheme, dense layout, rounded corners
============================================= */
:root {
/* Layout */
--left-col-width: 240px;
--cell-width: 100px;
--h-eod: 42px;
--h-status: 34px;
--h-dates: 52px;
/* Unified Color Palette */
--primary-color: #4f46e5; /* Indigo-600 */
--primary-light: #eef2ff; /* Indigo-50 */
--primary-dark: #3730a3; /* Indigo-800 */
--secondary-color: #0ea5e9; /* Sky-500 */
--success-color: #10b981; /* Emerald-500 */
--warning-color: #f59e0b; /* Amber-500 */
--danger-color: #ef4444; /* Red-500 */
--info-color: #8b5cf6; /* Violet-500 */
/* Neutral Colors */
--background-primary: #ffffff;
--background-secondary: #f8fafc;
--background-tertiary: #f1f5f9;
--border-color: #e2e8f0;
--border-color-light: #f1f5f9;
--border-color-dark: #cbd5e1;
--text-primary: #334155;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--text-white: #ffffff;
/* Highlight & Interactive */
--highlight-bg: rgba(99, 102, 241, 0.12);
--highlight-border: rgba(99, 102, 241, 0.5);
--hover-bg: rgba(241, 245, 249, 0.8);
--active-bg: rgba(79, 70, 229, 0.1);
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-rounded: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
/* Spacing (for dense layout) */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
--spacing-2xl: 24px;
}
/* MOBILE OPTIMIZATION */
@media (max-width: 599px) {
:root {
--left-col-width: 160px;
--spacing-xl: 12px;
--spacing-2xl: 16px;
}
}
body, html {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: var(--background-secondary);
color: var(--text-primary);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
}
/* Enhanced Scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--background-tertiary);
border-radius: var(--radius-rounded);
}
::-webkit-scrollbar-thumb {
background-color: var(--border-color-dark);
border-radius: var(--radius-rounded);
border: 2px solid var(--background-tertiary);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--text-tertiary);
}
/* Quasar overrides for consistent rounded corners */
.q-field--outlined .q-field__control,
.q-field--outlined.q-field--rounded .q-field__control,
.q-field--standard .q-field__control {
border-radius: var(--radius-lg) !important;
}
.q-btn--rounded,
.q-btn--rounded .q-btn__wrapper {
border-radius: var(--radius-lg) !important;
}
.q-select .q-field__control,
.q-input .q-field__control,
.q-checkbox .q-checkbox__inner,
.q-radio .q-radio__inner,
.q-toggle .q-toggle__track {
border-radius: var(--radius-md) !important;
}
.q-item {
border-radius: var(--radius-md) !important;
}
.q-card {
border-radius: var(--radius-xl) !important;
box-shadow: var(--shadow-md) !important;
border: 1px solid var(--border-color);
}
.q-drawer {
border-color: var(--border-color) !important;
}
/* Enhanced focus states */
.q-focusable:focus,
.q-focusable:hover,
.q-field--focused .q-field__control {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) !important;
}
/* Dense layout utilities */
.dense-padding {
padding: var(--spacing-md) !important;
}
.dense-margin {
margin: var(--spacing-md) !important;
}
.compact-row {
padding-top: var(--spacing-sm) !important;
padding-bottom: var(--spacing-sm) !important;
}