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,23 @@
<!DOCTYPE html>
<html>
<head>
<title>Hotline Planner</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
<link href="src/style.css" rel="stylesheet" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="q-app">
<!-- The App component will be mounted here and manage the layout -->
</div>
<!-- Dependencies -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
<!-- Entry Point -->
<script type="module" src="src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,321 @@
import PlannerHeader from './components/planner-header/planner-header.js';
import PlannerSettings from './components/planner-settings/planner-settings.js';
import PlannerFilter from './components/planner-filter/planner-filter.js';
import PlannerSidebar from './components/planner-sidebar/planner-sidebar.js';
import PlannerGrid from './components/planner-grid/planner-grid.js';
import {
state,
loadDataFromDatabase,
simulateWssLock,
simulateWssPresence,
saveAssignment as dbSaveAssignment,
getAssignment,
formatDateForId,
HUBS,
DEPARTMENTS,
ROLES,
SKILLS
} from './services/socket-service.js';
const { ref, computed, onMounted, watch } = Vue;
const { useQuasar } = Quasar;
export default {
name: 'App',
components: {
PlannerHeader,
PlannerSettings,
PlannerFilter,
PlannerSidebar,
PlannerGrid
},
setup() {
const $q = useQuasar();
// --- UI State ---
const leftDrawer = ref(false);
const filterDrawer = ref(false);
const rightDrawer = ref(false);
const editMode = ref('assignment');
const isCompact = ref(false);
const weekendsAreWorkingDays = ref(false);
const viewScope = ref(8);
const pickerStartDay = ref(1);
const search = ref("");
const activeDept = ref("All");
const activeHub = ref("All");
const filterRoles = ref([]);
const filterSkills = ref([]);
const filterByAvailability = ref(false);
const filterDate = ref(new Date().toISOString().split('T')[0]);
const filterShiftTypes = ref([]);
const showEodTargets = ref(false);
const showAvailability = ref(false);
const crosshairActive = ref(true);
const selectedAgent = ref(null);
const selectedDate = ref(null);
const pendingShift = ref(null);
const highlightedRowId = ref(null);
const highlightedDateStr = ref(null);
// --- Helpers ---
const getStartOfWeek = (date) => {
const d = new Date(date);
const day = d.getDay();
const diff = (day < 1 ? 7 : 0) + day - 1;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
};
const startDate = ref(getStartOfWeek(new Date()));
const dates = computed(() => {
const res = [];
const now = new Date(startDate.value);
for (let i = 0; i < viewScope.value * 7; i++) {
const d = new Date(now);
d.setDate(now.getDate() + i);
res.push(d);
}
return res;
});
const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
// --- Filtering Logic ---
const filteredAgents = computed(() => {
return state.agents.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;
});
});
const flattenedList = computed(() => {
const result = [];
const list = [...filteredAgents.value];
// Default grouping by hub > dept for now to match index.html logic
list.sort((a, b) => {
if (a.hub < b.hub) return -1;
if (a.hub > b.hub) return 1;
if (a.dept < b.dept) return -1;
if (a.dept > b.dept) return 1;
return 0;
});
let currentHub = null;
let currentDept = null;
list.forEach(agent => {
if (agent.hub !== currentHub) {
currentHub = agent.hub;
currentDept = null;
result.push({
type: 'header-l1',
label: HUBS.find(h => h.id === agent.hub)?.name || agent.hub,
id: `hub-${agent.hub}`
});
}
if (agent.dept !== currentDept) {
currentDept = agent.dept;
result.push({
type: 'header-l2',
label: agent.dept.toUpperCase() + ' DIVISION',
id: `dept-${agent.hub}-${agent.dept}`
});
}
result.push({ type: 'agent', data: agent, id: agent.id });
});
return result;
});
const currentAssignmentLabel = computed(() => {
if (pendingShift.value) return pendingShift.value.label;
if (!selectedAgent.value || !selectedDate.value) return null;
const a = getAssignment(selectedAgent.value.id, selectedDate.value);
return a ? a.label : null;
});
// --- Actions ---
const clearFilters = () => {
search.value = "";
activeDept.value = "All";
activeHub.value = "All";
filterRoles.value = [];
filterSkills.value = [];
filterByAvailability.value = false;
filterShiftTypes.value = [];
};
const applySavedFilter = (key) => {
clearFilters();
if (key === 'high_potential') filterSkills.value = ["VIP Concierge", "Technical Training"];
else if (key === 'remote') filterRoles.value = ["Specialist"];
};
const toggleRowHighlight = (id) => {
highlightedRowId.value = highlightedRowId.value === id ? null : id;
};
const toggleColHighlight = (date) => {
const str = formatDateForId(date);
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
};
const clearHighlights = () => {
highlightedRowId.value = null;
highlightedDateStr.value = null;
};
const openAssignment = (agent, date) => {
editMode.value = 'assignment';
selectedAgent.value = agent;
selectedDate.value = date;
pendingShift.value = null;
rightDrawer.value = true;
};
const openProfile = (agent) => {
editMode.value = 'profile';
selectedAgent.value = agent;
selectedDate.value = null;
rightDrawer.value = true;
};
const saveAssignment = () => {
if (!selectedAgent.value || !selectedDate.value) return;
dbSaveAssignment(selectedAgent.value.id, selectedDate.value, pendingShift.value ? pendingShift.value.id : null);
rightDrawer.value = false;
};
const resetToToday = () => {
startDate.value = getStartOfWeek(new Date());
leftDrawer.value = false;
};
const applyDateSelection = (date) => {
startDate.value = getStartOfWeek(date);
};
// --- Lifecycle ---
onMounted(() => {
loadDataFromDatabase(startDate.value, $q.notify);
simulateWssPresence($q.notify);
});
return {
state,
leftDrawer, filterDrawer, rightDrawer, editMode,
isCompact, weekendsAreWorkingDays, viewScope, pickerStartDay,
search, activeDept, activeHub, filterRoles, filterSkills, filterByAvailability, filterDate, filterShiftTypes,
showEodTargets, showAvailability, crosshairActive,
selectedAgent, selectedDate, pendingShift, currentAssignmentLabel,
highlightedRowId, highlightedDateStr,
dates, flattenedList,
clearFilters, applySavedFilter,
toggleRowHighlight, toggleColHighlight, clearHighlights,
openAssignment, openProfile, saveAssignment,
resetToToday, applyDateSelection,
simulateWssLock: () => simulateWssLock($q.notify)
};
},
template: `
<q-layout view="hHh Lpr fFf">
<planner-header
:has-highlights="!!(highlightedRowId || highlightedDateStr)"
@toggle-left="leftDrawer = !leftDrawer"
@clear-highlights="clearHighlights"
/>
<planner-settings
v-model="leftDrawer"
v-model:view-scope="viewScope"
v-model:picker-start-day="pickerStartDay"
v-model:crosshair-active="crosshairActive"
v-model:show-eod-targets="showEodTargets"
v-model:show-availability="showAvailability"
v-model:is-compact="isCompact"
v-model:weekends-are-working-days="weekendsAreWorkingDays"
@simulate-wss-lock="simulateWssLock"
@reset-to-today="resetToToday"
/>
<planner-filter
v-model="filterDrawer"
v-model:active-dept="activeDept"
v-model:active-hub="activeHub"
v-model:filter-roles="filterRoles"
v-model:filter-skills="filterSkills"
v-model:filter-by-availability="filterByAvailability"
v-model:filter-date="filterDate"
v-model:filter-shift-types="filterShiftTypes"
:picker-start-day="pickerStartDay"
@clear-filters="clearFilters"
@apply-saved-filter="applySavedFilter"
/>
<planner-sidebar
v-model="rightDrawer"
:edit-mode="editMode"
:selected-agent="selectedAgent"
:selected-date="selectedDate"
:current-assignment-label="currentAssignmentLabel"
:pending-shift="pendingShift"
@set-pending-shift="pendingShift = $event"
@save-assignment="saveAssignment"
/>
<q-page-container>
<q-page class="q-pa-none">
<planner-grid
:loading="state.loading"
:show-eod-targets="showEodTargets"
:show-availability="showAvailability"
:view-scope="viewScope"
:dates="dates"
:flattened-list="flattenedList"
:is-compact="isCompact"
:weekends-are-working-days="weekendsAreWorkingDays"
:crosshair-active="crosshairActive"
:highlighted-row-id="highlightedRowId"
:highlighted-date-str="highlightedDateStr"
v-model:search="search"
:is-filter-active="!!(search || activeDept !== 'All' || activeHub !== 'All' || filterRoles.length || filterSkills.length || filterByAvailability)"
:active-filter-count="0"
:picker-start-day="pickerStartDay"
@toggle-filter="filterDrawer = !filterDrawer"
@toggle-row-highlight="toggleRowHighlight"
@toggle-col-highlight="toggleColHighlight"
@open-assignment="openAssignment"
@open-profile="openProfile"
@apply-date-selection="applyDateSelection"
/>
</q-page>
</q-page-container>
</q-layout>
`
};

View File

@@ -0,0 +1,51 @@
.cell {
width: var(--cell-width);
min-width: var(--cell-width);
height: 58px;
border-right: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.1s ease;
}
.cell:hover:not(.cursor-not-allowed) {
background-color: #f8fafc;
}
.cell-compact {
height: 42px;
}
.shift-badge {
font-size: 9px;
font-weight: 800;
padding: 2px 4px;
border-radius: 4px;
width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.locked-overlay {
position: absolute;
inset: 0;
background: rgba(241, 245, 249, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.shift-tooltip {
padding: 10px 12px;
max-width: 220px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}

View File

@@ -0,0 +1,107 @@
import {
getAssignment,
isCellLocked,
hasComment,
hasNote,
getCommentText,
getNoteText,
formatDateForId
} from '../../services/socket-service.js';
const { computed } = Vue;
export default {
name: 'PlannerCell',
props: {
agent: Object,
date: Date,
weekendsAreWorkingDays: Boolean,
isCompact: Boolean,
isRowHighlighted: Boolean,
isColHighlighted: Boolean,
isHovered: Boolean
},
emits: ['click', 'mouseenter', 'mouseleave'],
setup(props) {
const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
const cellClass = computed(() => {
const isWknd = isWeekend(props.date) && !props.weekendsAreWorkingDays;
const locked = isCellLocked(props.agent.id, props.date);
const classes = ['planner-cell', 'cell'];
if (props.isCompact) classes.push('cell-compact');
if (isWknd) classes.push('bg-weekend', 'cursor-not-allowed');
else if (locked) classes.push('cursor-not-allowed');
else classes.push('cursor-pointer');
if (props.isRowHighlighted && props.isColHighlighted)
classes.push('bg-reading-mode-intersection');
else if (props.isRowHighlighted || props.isColHighlighted)
classes.push('bg-reading-mode');
if (props.isHovered) classes.push('col-hovered');
return classes.join(' ');
});
const assignment = computed(() => getAssignment(props.agent.id, props.date));
const locked = computed(() => isCellLocked(props.agent.id, props.date));
const comment = computed(() => hasComment(props.agent.id, props.date));
const note = computed(() => hasNote(props.agent.id, props.date));
const commentText = computed(() => getCommentText(props.agent.id, props.date));
const noteText = computed(() => getNoteText(props.agent.id, props.date));
return {
cellClass,
isWeekend,
assignment,
locked,
comment,
note,
commentText,
noteText
};
},
template: `
<div
:class="cellClass"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@click="$emit('click')"
>
<template v-if="weekendsAreWorkingDays || !isWeekend(date)">
<div v-if="assignment" class="shift-badge shadow-1" :class="assignment.badgeClass">
{{ assignment.label }}
</div>
</template>
<div v-if="locked" class="locked-overlay">
<q-icon name="lock" color="blue-grey-3" size="14px">
<q-tooltip class="bg-grey-9 text-white shadow-4 q-pa-sm">
This cell is currently being edited by another user.
</q-tooltip>
</q-icon>
</div>
<div class="absolute-bottom-right q-pa-xs row no-wrap" style="gap: 2px">
<q-icon v-if="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 shift-tooltip" anchor="top middle" self="bottom middle">
<div class="text-weight-bold text-caption text-indigo-9 q-mb-xs">User Comment</div>
<div class="text-caption text-grey-8">{{ commentText }}</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 shift-tooltip" anchor="top middle" self="bottom middle">
<div class="text-weight-bold text-caption text-orange-9 q-mb-xs">Technical Note</div>
<div class="text-caption text-grey-8">{{ noteText }}</div>
</q-tooltip>
</q-icon>
</div>
</div>
`
};

View File

@@ -0,0 +1,26 @@
.planner-filter .filter-section-title {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.1em;
color: #94a3b8;
margin-top: 24px;
margin-bottom: 12px;
text-transform: uppercase;
}
.filter-btn-active {
background-color: #e0e7ff !important;
border: 1px solid #c7d2fe !important;
}
.filter-status-dot {
width: 8px;
height: 8px;
background-color: #f59e0b;
border-radius: 50%;
position: absolute;
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px #e0e7ff;
z-index: 10;
}

View File

@@ -0,0 +1,164 @@
import { SHIFTS, HUBS, DEPARTMENTS, ROLES, SKILLS } from '../../services/socket-service.js';
const { ref } = Vue;
export default {
name: 'PlannerFilter',
props: {
modelValue: Boolean,
activeDept: String,
activeHub: String,
filterRoles: Array,
filterSkills: Array,
filterByAvailability: Boolean,
filterDate: String,
filterShiftTypes: Array,
pickerStartDay: Number
},
emits: [
'update:modelValue',
'update:activeDept',
'update:activeHub',
'update:filterRoles',
'update:filterSkills',
'update:filterByAvailability',
'update:filterDate',
'update:filterShiftTypes',
'clear-filters',
'apply-saved-filter'
],
setup(props, { emit }) {
const proxyFilterDate = ref(props.filterDate);
const updateFilterDateProxy = () => {
proxyFilterDate.value = props.filterDate;
};
const applyFilterDate = () => {
emit('update:filterDate', proxyFilterDate.value);
};
return {
HUBS,
DEPARTMENTS,
ROLES,
SKILLS,
SHIFTS,
proxyFilterDate,
updateFilterDateProxy,
applyFilterDate,
hubOptions: [
{ label: 'All Hubs', value: 'All' },
...HUBS.map(h => ({ label: h.name, value: h.id }))
]
};
},
template: `
<q-drawer
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
side="right"
bordered
:width="300"
class="planner-filter bg-grey-1"
>
<q-scroll-area class="fit">
<div class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-h6 text-weight-bold text-indigo-10">Filters</div>
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="$emit('update:modelValue', false)"></q-btn>
</div>
<div class="text-caption text-grey-6 q-mb-sm">Refine the agent list by skills and expertise.</div>
<q-btn flat rounded color="red-5" label="Clear All Filters" class="full-width q-mb-lg" size="sm" icon="filter_alt_off" @click="$emit('clear-filters')"></q-btn>
<div class="filter-section-title">Saved Views</div>
<q-list dense class="q-gutter-y-xs">
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'all')">
<q-item-section avatar><q-icon name="people_outline" size="xs"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">All Agents</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'high_potential')">
<q-item-section avatar><q-icon name="star_outline" size="xs" color="amber-8"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">High Expertise</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'remote')">
<q-item-section avatar><q-icon name="house_siding" size="xs" color="blue-6"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">Remote Support</q-item-section>
</q-item>
</q-list>
<div class="filter-section-title q-mt-lg">Shift Availability</div>
<div class="bg-white q-pa-sm rounded-borders border q-mb-md">
<q-toggle
:model-value="filterByAvailability"
@update:model-value="$emit('update:filterByAvailability', $event)"
label="Filter by Active Shift" dense class="q-mb-sm text-weight-medium text-caption" color="green-6"
></q-toggle>
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-xs animated fadeIn">
<q-input
:model-value="filterDate"
readonly
filled dense label="Target Date" class="text-caption"
>
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale" @before-show="updateFilterDateProxy">
<div class="column bg-white">
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal color="indigo-8" :first-day-of-week="pickerStartDay">
<div class="row items-center justify-end q-pa-sm border-t q-gutter-sm">
<q-btn v-close-popup label="Cancel" color="grey-7" flat dense></q-btn>
<q-btn v-close-popup label="OK" color="indigo-8" flat dense @click="applyFilterDate"></q-btn>
</div>
</q-date>
</div>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
<div class="text-caption text-grey-7 q-mb-xs">Required Shift Type:</div>
<div class="row q-gutter-xs">
<q-checkbox
v-for="s in SHIFTS" :key="s.id"
:model-value="filterShiftTypes"
@update:model-value="$emit('update:filterShiftTypes', $event)"
:val="s.id" :label="s.label" dense size="xs" :color="s.color.split('-')[0]" class="col-12 text-caption"
></q-checkbox>
</div>
</div>
</div>
<div class="filter-section-title">Hub Location</div>
<q-select
:model-value="activeHub"
@update:model-value="$emit('update:activeHub', $event)"
:options="hubOptions" emit-value map-options outlined dense rounded bg-color="white"
></q-select>
<div class="filter-section-title">Primary Division</div>
<q-select
:model-value="activeDept"
@update:model-value="$emit('update:activeDept', $event)"
:options="['All', ...DEPARTMENTS]" outlined dense rounded bg-color="white"
></q-select>
<div class="filter-section-title">Roles</div>
<q-select
:model-value="filterRoles"
@update:model-value="$emit('update:filterRoles', $event)"
:options="ROLES" multiple outlined dense rounded use-chips bg-color="white" placeholder="Filter by role..."
></q-select>
<div class="filter-section-title">Skills & Expertise</div>
<q-select
:model-value="filterSkills"
@update:model-value="$emit('update:filterSkills', $event)"
:options="SKILLS" multiple outlined dense rounded use-chips bg-color="white" placeholder="Select expertise..."
></q-select>
</div>
</q-scroll-area>
</q-drawer>
`
};

View File

@@ -0,0 +1,209 @@
:root {
--left-col-width: 240px;
--cell-width: 100px;
--h-eod: 42px;
--h-status: 34px;
--h-dates: 52px;
--border-color: #e2e8f0;
--highlight-bg: rgba(99, 102, 241, 0.12);
--highlight-border: rgba(99, 102, 241, 0.5);
}
@media (max-width: 599px) {
:root {
--left-col-width: 160px;
}
}
.grid-viewport {
height: calc(100vh - 64px) !important;
overflow: auto !important;
background: white;
position: relative;
display: block;
border-top: 1px solid var(--border-color);
}
.planner-content {
display: inline-block;
min-width: fit-content;
vertical-align: top;
}
.planner-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f1f5f9;
}
.left-col {
width: var(--left-col-width);
min-width: var(--left-col-width);
position: sticky;
left: 0;
z-index: 20;
background: white;
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
}
/* Sticky Header Classes */
.row-eod {
position: sticky;
top: 0;
z-index: 40;
height: var(--h-eod);
background: #fffafb;
}
.row-status {
position: sticky;
top: var(--h-eod);
z-index: 39;
height: var(--h-status);
background: white;
}
.row-dates {
position: sticky;
top: calc(var(--h-eod) + var(--h-status));
z-index: 38;
height: var(--h-dates);
background: #fdfdfd;
}
.row-eod .left-col {
z-index: 60;
background: #fffafb;
color: #db2777;
border-bottom: 1px solid #fce7f3;
}
.row-status .left-col {
z-index: 59;
background: white;
color: #64748b;
}
.row-dates .left-col {
z-index: 58;
background: #fdfdfd;
padding-right: 12px;
border-bottom: 1px solid var(--border-color);
}
.cells-area {
display: flex;
flex-direction: row;
}
/* Group Headers */
.group-header-l1 {
background: #eef2ff;
font-size: 12px;
font-weight: 900;
color: #3730a3;
letter-spacing: 0.05em;
height: 36px;
border-top: 1px solid #c7d2fe;
border-bottom: 1px solid #e0e7ff;
}
.group-header-l2 {
background: #f8fafc;
font-size: 11px;
font-weight: 800;
color: #64748b;
letter-spacing: 0.025em;
height: 32px;
border-top: 1px solid #e2e8f0;
}
.group-header-l1 .left-col {
background: #eef2ff;
color: #3730a3;
}
.group-header-l2 .left-col {
background: #f8fafc;
color: #64748b;
padding-left: 24px;
}
/* Highlight Classes */
.bg-reading-mode {
background-color: #fff9c4 !important;
}
.bg-reading-mode-intersection {
background-color: #fff176 !important;
}
.reading-active-header {
background-color: #fef08a !important;
border-bottom: 2px solid #eab308;
}
.row-highlighted .left-col {
background-color: #fff9c4 !important;
}
.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;
}
.planner-row-item.crosshair-enabled:hover {
background-color: var(--highlight-bg);
box-shadow: inset 0 1px 0 var(--highlight-border), inset 0 -1px 0 var(--highlight-border);
}
.planner-row-item.crosshair-enabled:hover .left-col {
background-color: #e0e7ff !important;
border-top: 1px solid var(--highlight-border);
border-bottom: 1px solid var(--highlight-border);
color: #3730a3;
}
.grid-search-container {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.bg-weekend {
background-color: #f1f5f9 !important;
}
.text-sat {
color: #166534 !important;
}
.text-sun {
color: #991b1b !important;
}
.agent-row-hover-trigger .agent-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.agent-row-hover-trigger:hover .agent-highlight-btn,
.agent-highlight-btn.is-active {
opacity: 1;
}
.date-header-hover-trigger .header-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.date-header-hover-trigger:hover .header-highlight-btn,
.header-highlight-btn.is-active {
opacity: 1;
}

View File

@@ -0,0 +1,263 @@
import PlannerCell from '../planner-cell/planner-cell.js';
import { getHoliday, getSpecialDay, formatDateForId } from '../../services/socket-service.js';
const { ref, computed, nextTick } = Vue;
export default {
name: 'PlannerGrid',
components: {
PlannerCell
},
props: {
loading: Boolean,
showEodTargets: Boolean,
showAvailability: Boolean,
viewScope: Number,
dates: Array,
flattenedList: Array,
isCompact: Boolean,
weekendsAreWorkingDays: Boolean,
crosshairActive: Boolean,
highlightedRowId: [Number, String],
highlightedDateStr: String,
search: String,
isFilterActive: Boolean,
activeFilterCount: Number,
pickerStartDay: Number
},
emits: [
'update:search',
'toggle-filter',
'toggle-row-highlight',
'toggle-col-highlight',
'open-assignment',
'open-profile',
'reset-to-today',
'apply-date-selection'
],
setup(props, { emit }) {
const hoveredDateStr = ref(null);
const dateMenu = ref(false);
const proxyDate = ref(null);
const viewport = ref(null);
const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
const gridStyles = computed(() => ({
'--h-eod': props.showEodTargets ? '42px' : '0px',
'--h-status': props.showAvailability ? '34px' : '0px'
}));
const syncProxyDate = () => {
proxyDate.value = formatDateForId(new Date());
};
const applyDateSelection = () => {
if (!proxyDate.value) return;
emit('apply-date-selection', new Date(proxyDate.value));
dateMenu.value = false;
nextTick(() => {
if (viewport.value) viewport.value.scrollLeft = 0;
});
};
return {
hoveredDateStr,
dateMenu,
proxyDate,
viewport,
isWeekend,
gridStyles,
getHoliday,
getSpecialDay,
formatDateForId,
syncProxyDate,
applyDateSelection
};
},
template: `
<div id="viewport-target" class="grid-viewport" ref="viewport" :style="gridStyles">
<div class="planner-content">
<!-- EOD Targets Row -->
<div v-if="showEodTargets" class="planner-row row-eod">
<div class="left-col text-overline text-weight-bold">EOD Targets</div>
<div class="cells-area">
<div v-for="(d, i) in dates" :key="'e'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-eod)">
<div class="text-weight-bold text-[10px] text-pink-7">94%</div>
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded style="width: 60%"></q-linear-progress>
</div>
</div>
</div>
<!-- Availability Row -->
<div v-if="showAvailability" class="planner-row row-status">
<div class="left-col text-overline text-grey-5">Availability</div>
<div class="cells-area">
<div v-for="(d, i) in dates" :key="'s'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-status)">
<q-icon name="circle" :color="i % 10 === 0 ? 'red-3' : (i % 5 === 0 ? 'orange-3' : 'green-3')" size="8px"></q-icon>
</div>
</div>
</div>
<!-- Dates Header Row -->
<div class="planner-row row-dates">
<div class="left-col">
<div class="grid-search-container">
<q-input
:model-value="search"
@update:model-value="$emit('update:search', $event)"
debounce="300" dense outlined rounded
:placeholder="$q.screen.lt.sm ? 'Search' : 'Quick search...'"
class="full-width" bg-color="white" clearable
>
<template v-slot:prepend><q-icon name="search" size="xs" color="grey-5"></q-icon></template>
</q-input>
<q-btn flat round dense icon="filter_alt" :color="isFilterActive ? 'indigo-10' : 'grey-6'" :class="isFilterActive ? 'filter-btn-active' : ''" size="sm" @click="$emit('toggle-filter')">
<div v-if="isFilterActive" class="filter-status-dot"></div>
<q-tooltip>{{ isFilterActive ? activeFilterCount + ' filters active' : 'Advanced Filters & Expertise' }}</q-tooltip>
</q-btn>
<q-btn flat round dense icon="calendar_today" color="indigo-7" size="sm">
<q-menu v-model="dateMenu" @show="syncProxyDate">
<q-card class="dense-date-card shadow-12 border">
<q-card-section class="q-pa-none"><q-date v-model="proxyDate" minimal flat color="indigo-8" :first-day-of-week="pickerStartDay"></q-date></q-card-section>
<q-card-actions align="right" class="q-pt-none q-pb-sm q-px-md">
<q-btn flat dense rounded label="Cancel" color="grey-7" @click="dateMenu = false"></q-btn>
<q-btn unelevated dense rounded label="OK" color="indigo-8" class="q-px-md" @click="applyDateSelection"></q-btn>
</q-card-actions>
</q-card>
</q-menu>
</q-btn>
</div>
</div>
<div class="cells-area">
<div v-for="(date, i) in dates"
:key="'d'+i"
class="cell column flex-center relative-position date-header-hover-trigger transition-colors"
:class="[
isWeekend(date) && !weekendsAreWorkingDays ? 'bg-weekend' : '',
highlightedDateStr === formatDateForId(date) ? 'reading-active-header' : '',
crosshairActive && hoveredDateStr === formatDateForId(date) ? 'col-hovered' : ''
]"
style="height: var(--h-dates)"
@mouseenter="hoveredDateStr = formatDateForId(date)"
@mouseleave="hoveredDateStr = null"
>
<div class="absolute-top-right q-pa-xs">
<q-icon v-if="getHoliday(date)" name="celebration" size="10px" color="red-5" class="cursor-help">
<q-tooltip class="bg-red-9 text-white shadow-4 q-pa-sm">Holiday: {{ getHoliday(date) }}</q-tooltip>
</q-icon>
<q-icon v-else-if="getSpecialDay(date)" name="bookmark" size="10px" color="indigo-4" class="cursor-help">
<q-tooltip class="bg-indigo-9 text-white shadow-4 q-pa-sm">Event: {{ getSpecialDay(date) }}</q-tooltip>
</q-icon>
</div>
<div class="text-[10px] uppercase text-weight-bold" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-5']">
{{ date.toLocaleDateString('en-US', {weekday: 'short'}) }}
</div>
<div class="text-subtitle2 text-weight-bold leading-none" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-9']">
{{ date.getDate() }}. {{ date.toLocaleDateString('en-US', { month: 'short' }) }}
</div>
<q-btn round flat dense
:icon="highlightedDateStr === formatDateForId(date) ? 'visibility_off' : 'visibility'"
:color="highlightedDateStr === formatDateForId(date) ? 'amber-9' : 'grey-5'"
size="sm"
class="header-highlight-btn absolute-bottom-right q-ma-xs"
:class="{ 'is-active': highlightedDateStr === formatDateForId(date) }"
@click.stop="$emit('toggle-col-highlight', date)">
<q-tooltip>{{ highlightedDateStr === formatDateForId(date) ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
</q-btn>
<div v-if="highlightedDateStr === formatDateForId(date)" class="absolute-bottom full-width bg-amber-8" style="height: 3px;"></div>
</div>
</div>
</div>
<!-- Skeleton Loading -->
<div v-if="loading">
<div v-for="n in 15" :key="n" class="planner-row">
<div class="left-col items-center">
<q-skeleton type="QAvatar" size="32px"></q-skeleton>
<div class="col q-ml-sm">
<q-skeleton type="text" width="60%"></q-skeleton>
<q-skeleton type="text" width="40%"></q-skeleton>
</div>
</div>
<div class="cells-area">
<div v-for="m in (viewScope || 8) * 7" :key="m" class="cell flex flex-center">
<q-skeleton type="rect" width="70%" height="20px"></q-skeleton>
</div>
</div>
</div>
</div>
<!-- Virtual Scroll Content -->
<q-virtual-scroll
v-if="!loading"
scroll-target="#viewport-target"
:items="flattenedList"
:item-size="isCompact ? 43 : 59"
class="virtual-scroll-target"
>
<template v-slot="{ item, index }">
<div v-if="item.type === 'header-l1'" :key="item.id" class="planner-row group-header-l1">
<div class="left-col group-header-l1">{{ item.label }}</div>
<div class="cells-area"><div class="cell" style="flex: 1; height: 36px; border-right: none"></div></div>
</div>
<div v-else-if="item.type === 'header-l2'" :key="item.id" class="planner-row group-header-l2">
<div class="left-col group-header-l2">{{ item.label }}</div>
<div class="cells-area"><div class="cell" style="flex: 1; height: 32px; border-right: none"></div></div>
</div>
<div
v-else
:key="item.id"
class="planner-row planner-row-item"
:class="{'row-highlighted': highlightedRowId === item.data.id, 'crosshair-enabled': crosshairActive}"
>
<div class="left-col border-b cursor-pointer agent-row-hover-trigger relative-position"
:class="[isCompact ? 'cell-compact' : '']"
@click="$emit('open-profile', item.data)">
<q-avatar :size="isCompact ? '24px' : '32px'" class="shadow-1"><img :src="item.data.avatar"></q-avatar>
<div class="q-ml-sm overflow-hidden col">
<div class="text-weight-bold truncate" :style="{fontSize: isCompact ? '11px' : '13px'}">{{ item.data.name }}</div>
<div v-if="!isCompact" class="text-[10px] text-grey-5 uppercase text-weight-bold truncate agent-role">{{ item.data.role }}</div>
</div>
<q-btn round flat dense
:icon="highlightedRowId === item.data.id ? 'visibility_off' : 'visibility'"
:color="highlightedRowId === item.data.id ? 'amber-9' : 'grey-4'"
size="sm"
class="agent-highlight-btn"
:class="{ 'is-active': highlightedRowId === item.data.id }"
@click.stop="$emit('toggle-row-highlight', item.data.id)">
<q-tooltip>{{ highlightedRowId === item.data.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
</q-btn>
</div>
<div class="cells-area">
<planner-cell
v-for="(date, i) in dates"
:key="'c'+item.data.id+i"
:agent="item.data"
:date="date"
:weekends-are-working-days="weekendsAreWorkingDays"
:is-compact="isCompact"
:is-row-highlighted="highlightedRowId === item.data.id"
:is-col-highlighted="highlightedDateStr === formatDateForId(date)"
:is-hovered="crosshairActive && hoveredDateStr === formatDateForId(date)"
@mouseenter="hoveredDateStr = formatDateForId(date)"
@mouseleave="hoveredDateStr = null"
@click="$emit('open-assignment', item.data, date)"
/>
</div>
</div>
</template>
</q-virtual-scroll>
</div>
</div>
`
};

View File

@@ -0,0 +1,7 @@
.planner-header {
border-bottom: 1px solid #e2e8f0;
}
.planner-header .q-toolbar {
min-height: 64px;
}

View File

@@ -0,0 +1,50 @@
import PlannerUsersOnline from '../planner-users-online/planner-users-online.js';
export default {
name: 'PlannerHeader',
components: {
PlannerUsersOnline
},
props: {
hasHighlights: Boolean
},
emits: ['toggle-left', 'clear-highlights'],
template: `
<q-header class="planner-header bg-white text-grey-9">
<q-toolbar class="q-py-sm">
<q-btn flat round dense icon="menu" color="grey-7" @click="$emit('toggle-left')"></q-btn>
<div class="row items-center q-ml-md">
<q-toolbar-title class="text-weight-bold text-subtitle1 text-indigo-10 ellipsis" style="max-width: 150px;">
Hotline Planner (AG gemini flash)
</q-toolbar-title>
</div>
<q-space></q-space>
<div class="row items-center q-gutter-sm">
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
<q-btn v-if="hasHighlights"
outline rounded dense
color="amber-9"
icon="highlight_off"
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
class="q-mr-xs bg-amber-1"
@click="$emit('clear-highlights')">
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
</q-btn>
</transition>
<planner-users-online />
<q-separator vertical inset class="gt-xs q-mx-sm"></q-separator>
<q-btn flat round dense icon="notifications" color="grey-6" class="gt-xs"></q-btn>
<q-avatar size="32px" class="cursor-pointer q-ml-xs">
<img src="https://i.pravatar.cc/150?u=me">
</q-avatar>
</div>
</q-toolbar>
</q-header>
`
};

View File

@@ -0,0 +1,9 @@
.planner-settings .q-drawer {
background-color: white;
}
.planner-settings .section-title {
font-weight: bold;
color: #1a237e;
/* indigo-10 */
}

View File

@@ -0,0 +1,155 @@
export default {
name: 'PlannerSettings',
props: {
modelValue: Boolean,
viewScope: Number,
pickerStartDay: Number,
crosshairActive: Boolean,
showEodTargets: Boolean,
showAvailability: Boolean,
isCompact: Boolean,
weekendsAreWorkingDays: Boolean
},
emits: [
'update:modelValue',
'update:viewScope',
'update:pickerStartDay',
'update:crosshairActive',
'update:showEodTargets',
'update:showAvailability',
'update:isCompact',
'update:weekendsAreWorkingDays',
'simulate-wss-lock',
'reset-to-today'
],
template: `
<q-drawer
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
side="left"
bordered
:width="300"
class="planner-settings 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
:model-value="viewScope"
@update:model-value="$emit('update:viewScope', $event)"
: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
:model-value="pickerStartDay"
@update:model-value="$emit('update:pickerStartDay', $event)"
: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>
<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="$emit('simulate-wss-lock')"
>
<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
:model-value="crosshairActive"
@update:model-value="$emit('update:crosshairActive', $event)"
color="indigo-8"
></q-toggle>
</q-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
:model-value="showEodTargets"
@update:model-value="$emit('update:showEodTargets', $event)"
color="pink-6"
></q-toggle>
</q-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
:model-value="showAvailability"
@update:model-value="$emit('update:showAvailability', $event)"
color="orange-6"
></q-toggle>
</q-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
:model-value="isCompact"
@update:model-value="$emit('update:isCompact', $event)"
color="indigo-8"
></q-toggle>
</q-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
:model-value="weekendsAreWorkingDays"
@update:model-value="$emit('update:weekendsAreWorkingDays', $event)"
color="indigo-8"
></q-toggle>
</q-section>
</q-item>
<q-btn outline rounded color="indigo-8" label="Reset to Today" icon="today" class="full-width q-mt-md" @click="$emit('reset-to-today')"></q-btn>
</div>
</div>
</q-scroll-area>
</q-drawer>
`
};

View File

@@ -0,0 +1,15 @@
.planner-sidebar .q-drawer {
background-color: white;
}
.planner-sidebar .border-b {
border-bottom: 1px solid #e2e8f0;
}
.planner-sidebar .border-t {
border-top: 1px solid #e2e8f0;
}
.planner-sidebar .border {
border: 1px solid #e2e8f0;
}

View File

@@ -0,0 +1,95 @@
import { SHIFTS } from '../../services/socket-service.js';
export default {
name: 'PlannerSidebar',
props: {
modelValue: Boolean,
editMode: String,
selectedAgent: Object,
selectedDate: [Date, String],
currentAssignmentLabel: String,
pendingShift: Object
},
emits: [
'update:modelValue',
'set-pending-shift',
'save-assignment'
],
setup() {
return { SHIFTS };
},
template: `
<q-drawer
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
side="right"
bordered
:width="380"
overlay
elevated
class="planner-sidebar"
>
<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="$emit('update:modelValue', false)"></q-btn>
</q-toolbar>
<q-scroll-area class="col q-pa-lg">
<div v-if="selectedAgent" class="row items-center q-mb-xl">
<q-avatar size="64px" class="shadow-1"><img :src="selectedAgent.avatar"></q-avatar>
<div class="q-ml-md">
<div class="text-h6 text-weight-bold">{{ selectedAgent.name }}</div>
<div class="text-caption text-grey-7">{{ selectedAgent.dept }} • {{ selectedAgent.role }}</div>
<div class="text-caption text-indigo-8 text-weight-bold q-mt-xs">
<q-icon name="public" size="xs" class="q-mr-xs"></q-icon> {{ selectedAgent.hubName || selectedAgent.hub }}
</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">
{{ new Date(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="$emit('set-pending-shift', 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="$emit('set-pending-shift', null)"
></q-btn>
</div>
</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="$emit('update:modelValue', false)"></q-btn>
<q-btn unelevated rounded color="indigo-10" label="Save Assignment" class="col" @click="$emit('save-assignment')"></q-btn>
</div>
</div>
</q-drawer>
`
};

View File

@@ -0,0 +1,40 @@
.avatar-stack-item {
margin-left: -8px;
border: 2px solid white;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.avatar-stack-img:hover {
transform: translateY(-2px);
z-index: 10;
border-color: #cbd5e1;
}
.avatar-stack-count-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #f1f5f9;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 5;
border: 2px solid white;
}
.profile-card {
min-width: 240px;
max-width: 320px;
padding: 12px 16px;
background: white;
border-radius: 8px;
}
.no-shadow-card {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
}

View File

@@ -0,0 +1,87 @@
import { state } from '../../services/socket-service.js';
const { computed } = Vue;
export default {
name: 'PlannerUsersOnline',
template: `
<div class="planner-users-online row items-center">
<!-- Desktop View -->
<div class="row items-center gt-sm q-mr-sm">
<div class="row items-center no-wrap">
<q-avatar v-for="user in visibleOnlineUsers" :key="user.id" size="32px" class="avatar-stack-item avatar-stack-img">
<img :src="user.img">
<q-tooltip anchor="bottom middle" self="top middle" :offset="[0, 10]" class="bg-white text-grey-9 shadow-10 q-pa-none rounded-borders no-shadow-card" style="border: 1px solid #e2e8f0; max-width: none">
<div class="profile-card row no-wrap items-center">
<q-avatar size="54px" class="q-mr-md shadow-1"><img :src="user.img"></q-avatar>
<div class="column">
<div class="text-weight-bold text-body2">{{ user.name }}</div>
<div class="text-caption text-grey-6">{{ user.role }}</div>
<div class="text-caption text-blue-6 text-weight-medium">Online Now</div>
</div>
</div>
</q-tooltip>
</q-avatar>
<div v-if="remainingOnlineCount > 0" class="avatar-stack-item avatar-stack-count-circle cursor-pointer">
+{{ remainingOnlineCount }}
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
<q-list dense style="min-width: 240px">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Active Collaborators</q-item-label>
<q-separator></q-separator>
<q-scroll-area style="height: 250px;">
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</div>
</div>
</div>
<!-- Mobile/Tablet View -->
<div class="lt-md">
<q-btn round flat dense color="grey-6" icon="group">
<q-badge color="red" floating rounded>{{ onlineUsers.length }}</q-badge>
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
<q-list dense style="min-width: 240px">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Online Now</q-item-label>
<q-separator></q-separator>
<q-scroll-area style="height: 250px;">
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
`,
setup() {
const onlineUsers = computed(() => state.onlineUsers);
const visibleOnlineUsers = computed(() => {
const list = onlineUsers.value;
// <= 5 show all, no +n
// > 5 show 5 and +n
if (list.length <= 5) return list;
return list.slice(0, 5);
});
const remainingOnlineCount = computed(() => {
const list = onlineUsers.value;
if (list.length <= 5) return 0;
return list.length - 5;
});
return {
onlineUsers,
visibleOnlineUsers,
remainingOnlineCount
};
}
};

View File

@@ -0,0 +1,15 @@
import App from './App.js';
const { createApp } = Vue;
const app = createApp(App);
app.use(Quasar, {
config: {
brand: {
primary: '#334155'
}
}
});
app.mount('#q-app');

View File

@@ -0,0 +1,216 @@
/* ===================================================
* Socket Service Data Layer & WSS Simulation
* =================================================== */
const { reactive } = Vue;
// ─── 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' }
];
const MOCK_COMMENTS = ['Late arrival expected.', 'Dental appointment.', 'Swapped shift.', 'Priority focus.', 'Remote session.'];
const MOCK_NOTES = ['Headset check.', 'VPN slow.', 'Training session.', 'Backup Billing.', 'Overtime pending.'];
// ─── Reactive State ──────────────────────────────────
export const state = reactive({
agents: [],
assignments: {},
comments: {},
notes: {},
holidays: {},
specialDays: {},
lockedCells: new Set(),
onlineUsers: reactive([]),
loading: false
});
// ─── Mock Data Pool for Presence Simulation ─────────
const USER_POOL = Array.from({ length: 50 }, (_, i) => ({
id: 'u' + (i + 1),
name: 'User ' + (i + 1),
role: ROLES[i % ROLES.length] + ' Collaborator',
img: `https://i.pravatar.cc/150?u=${i + 100}`
}));
// ─── Helpers ─────────────────────────────────────────
export function 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('-');
}
function 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
};
});
}
// ─── Actions ─────────────────────────────────────────
export function loadDataFromDatabase(startDate, notifyFn) {
state.loading = true;
setTimeout(() => {
const fetched = generateMockAgents(800);
state.agents = fetched;
const base = new Date(startDate);
fetched.forEach(agent => {
if (!state.assignments[agent.id]) state.assignments[agent.id] = {};
if (!state.comments[agent.id]) state.comments[agent.id] = {};
if (!state.notes[agent.id]) state.notes[agent.id] = {};
for (let i = 0; i < 60; i++) {
const d = new Date(base);
d.setDate(d.getDate() + i);
const dStr = formatDateForId(d);
if ((agent.id + i) % 7 === 0) state.assignments[agent.id][dStr] = SHIFTS[0].id;
else if ((agent.id + i) % 5 === 0) state.assignments[agent.id][dStr] = SHIFTS[1].id;
if ((agent.id + i) % 20 === 0) state.comments[agent.id][dStr] = MOCK_COMMENTS[(agent.id + i) % MOCK_COMMENTS.length];
if ((agent.id + i) % 25 === 0) state.notes[agent.id][dStr] = MOCK_NOTES[(agent.id + i) % MOCK_NOTES.length];
}
});
for (const key in state.holidays) delete state.holidays[key];
for (const key in state.specialDays) delete state.specialDays[key];
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
state.holidays[formatDateForId(tomorrow)] = 'Regional Holiday';
const eventDay = new Date(today);
eventDay.setDate(today.getDate() + 5);
state.specialDays[formatDateForId(eventDay)] = 'Quarterly Planning';
state.loading = false;
if (notifyFn) notifyFn({ message: 'Data Loaded: 800 Agents', color: 'positive', position: 'top', timeout: 1000 });
}, 2000);
}
export function handleWssMessage(type, payload, notifyFn) {
if (type === 'LOCK_CELL') {
const key = payload.agentId + ':' + payload.date;
state.lockedCells.add(key);
if (notifyFn) {
const agent = state.agents.find(a => a.id === payload.agentId);
const name = agent ? agent.name : 'Unknown Agent';
notifyFn({ message: 'WSS: Cell Locked for ' + name + ' on ' + payload.date, color: 'negative', position: 'top', icon: 'lock' });
}
} else if (type === 'USER_ONLINE') {
const alreadyOnline = state.onlineUsers.find(u => u.id === payload.user.id);
if (!alreadyOnline) {
state.onlineUsers.push(payload.user);
if (notifyFn) notifyFn({ message: payload.user.name + ' joined', color: 'info', position: 'bottom-right', timeout: 2000 });
}
} else if (type === 'USER_OFFLINE') {
const index = state.onlineUsers.findIndex(u => u.id === payload.userId);
if (index > -1) {
const user = state.onlineUsers[index];
state.onlineUsers.splice(index, 1);
if (notifyFn) notifyFn({ message: user.name + ' left', color: 'grey-7', position: 'bottom-right', timeout: 2000 });
}
}
}
export function simulateWssPresence(notifyFn) {
const isAdding = Math.random() > 0.4 || state.onlineUsers.length < 3;
if (isAdding && state.onlineUsers.length < 15) {
// Add random user from pool not already online
const offlineUsers = USER_POOL.filter(u => !state.onlineUsers.find(ou => ou.id === u.id));
if (offlineUsers.length > 0) {
const user = offlineUsers[Math.floor(Math.random() * offlineUsers.length)];
handleWssMessage('USER_ONLINE', { user }, notifyFn);
}
} else if (state.onlineUsers.length > 1) {
// Remove random user
const user = state.onlineUsers[Math.floor(Math.random() * state.onlineUsers.length)];
handleWssMessage('USER_OFFLINE', { userId: user.id }, notifyFn);
}
// Schedule next update
const nextInterval = 3000 + Math.random() * 7000;
setTimeout(() => simulateWssPresence(notifyFn), nextInterval);
}
export function simulateWssLock(notifyFn) {
if (state.agents.length === 0) return;
const agent = state.agents[Math.floor(Math.random() * state.agents.length)];
let target = new Date();
const day = target.getDay();
if (day === 0) target.setDate(target.getDate() + 1);
else if (day === 6) target.setDate(target.getDate() + 2);
handleWssMessage('LOCK_CELL', { agentId: agent.id, date: formatDateForId(target) }, notifyFn);
}
// ─── Getters ─────────────────────────────────────────
export function getAssignment(agentId, date) {
const dStr = formatDateForId(date);
if (state.assignments[agentId] && state.assignments[agentId][dStr] !== undefined) {
return SHIFTS.find(s => s.id === state.assignments[agentId][dStr]);
}
return null;
}
export function isCellLocked(agentId, date) {
return state.lockedCells.has(agentId + ':' + formatDateForId(date));
}
export function hasComment(agentId, date) {
const dStr = formatDateForId(date);
return !!(state.comments[agentId] && state.comments[agentId][dStr]);
}
export function getCommentText(agentId, date) {
const dStr = formatDateForId(date);
return state.comments[agentId] ? state.comments[agentId][dStr] || '' : '';
}
export function hasNote(agentId, date) {
const dStr = formatDateForId(date);
return !!(state.notes[agentId] && state.notes[agentId][dStr]);
}
export function getNoteText(agentId, date) {
const dStr = formatDateForId(date);
return state.notes[agentId] ? state.notes[agentId][dStr] || '' : '';
}
export function getHoliday(date) { return state.holidays[formatDateForId(date)] || null; }
export function getSpecialDay(date) { return state.specialDays[formatDateForId(date)] || null; }
// ─── Mutations ───────────────────────────────────────
export function saveAssignment(agentId, date, shiftId) {
const dStr = formatDateForId(date);
if (!state.assignments[agentId]) state.assignments[agentId] = {};
state.assignments[agentId][dStr] = shiftId;
}

View File

@@ -0,0 +1,39 @@
@import "./components/planner-header/planner-header.css";
@import "./components/planner-users-online/planner-users-online.css";
@import "./components/planner-settings/planner-settings.css";
@import "./components/planner-filter/planner-filter.css";
@import "./components/planner-sidebar/planner-sidebar.css";
@import "./components/planner-grid/planner-grid.css";
@import "./components/planner-cell/planner-cell.css";
body,
html {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: #f8fafc;
color: #334155;
font-family: 'Roboto', sans-serif;
}
.grid-viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.grid-viewport::-webkit-scrollbar-track {
background: #f1f5f9;
}
.grid-viewport::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 20px;
border: 2px solid #f1f5f9;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,15 @@
#!/bin/bash
PORT=5187
cd "$(dirname "$0")"
# Cleanup existing process on the specified port
lsof -ti:$PORT | xargs kill -9 2>/dev/null
echo "Starting Hotline Planner on port $PORT..."
python3 -m http.server $PORT &
sleep 1
open "http://localhost:$PORT"
echo "Server active. Press Ctrl+C to stop."
wait

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="src/components/app-shell/app-shell.css">
<link rel="stylesheet" href="src/components/app-header/app-header.css">
<link rel="stylesheet" href="src/components/header-brand/header-brand.css">
<link rel="stylesheet" href="src/components/header-actions/header-actions.css">
<link rel="stylesheet" href="src/components/online-users-stack/online-users-stack.css">
<link rel="stylesheet" href="src/components/online-users-menu/online-users-menu.css">
<link rel="stylesheet" href="src/components/left-drawer/left-drawer.css">
<link rel="stylesheet" href="src/components/timeline-settings/timeline-settings.css">
<link rel="stylesheet" href="src/components/dev-tools/dev-tools.css">
<link rel="stylesheet" href="src/components/grid-toggles/grid-toggles.css">
<link rel="stylesheet" href="src/components/reset-controls/reset-controls.css">
<link rel="stylesheet" href="src/components/filter-drawer/filter-drawer.css">
<link rel="stylesheet" href="src/components/filter-header/filter-header.css">
<link rel="stylesheet" href="src/components/filter-saved-views/filter-saved-views.css">
<link rel="stylesheet" href="src/components/filter-availability/filter-availability.css">
<link rel="stylesheet" href="src/components/filter-hub/filter-hub.css">
<link rel="stylesheet" href="src/components/filter-division/filter-division.css">
<link rel="stylesheet" href="src/components/filter-roles/filter-roles.css">
<link rel="stylesheet" href="src/components/filter-skills/filter-skills.css">
<link rel="stylesheet" href="src/components/detail-drawer/detail-drawer.css">
<link rel="stylesheet" href="src/components/agent-summary/agent-summary.css">
<link rel="stylesheet" href="src/components/assignment-editor/assignment-editor.css">
<link rel="stylesheet" href="src/components/planner-page/planner-page.css">
<link rel="stylesheet" href="src/components/loading-skeleton/loading-skeleton.css">
<link rel="stylesheet" href="src/components/planner-grid/planner-grid.css">
<link rel="stylesheet" href="src/components/eod-row/eod-row.css">
<link rel="stylesheet" href="src/components/availability-row/availability-row.css">
<link rel="stylesheet" href="src/components/dates-row/dates-row.css">
<link rel="stylesheet" href="src/components/grid-search/grid-search.css">
<link rel="stylesheet" href="src/components/date-cell/date-cell.css">
<link rel="stylesheet" href="src/components/group-header-row/group-header-row.css">
<link rel="stylesheet" href="src/components/agent-row/agent-row.css">
<link rel="stylesheet" href="src/components/agent-cell/agent-cell.css">
<link rel="stylesheet" href="src/components/day-cell/day-cell.css">
<link rel="stylesheet" href="src/components/cell-badges/cell-badges.css">
</head>
<body>
<div id="q-app"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
<script type="module" src="src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
.agent-cell-compact {
height: 42px;
}
.agent-cell-name {
font-size: 13px;
}
.agent-cell-name-compact {
font-size: 11px;
}
.agent-cell-role {
font-size: 10px;
}
.agent-cell-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.agent-cell-highlight-btn-active {
opacity: 1;
}
.agent-cell-hover-trigger:hover .agent-cell-highlight-btn {
opacity: 1;
}
@media (max-width: 599px) {
.agent-cell-role {
display: none;
}
}

View File

@@ -0,0 +1,47 @@
const { inject, computed } = Vue;
export default {
name: 'AgentCell',
props: {
agent: { type: Object, required: true }
},
setup(props) {
const appState = inject('appState');
const compactClass = computed(() => appState.isCompact.value ? 'agent-cell-compact' : '');
const nameClass = computed(() => appState.isCompact.value ? 'agent-cell-name-compact' : 'agent-cell-name');
const highlightBtnClass = computed(() => [
'agent-cell-highlight-btn',
appState.highlightedRowId.value === props.agent.id ? 'agent-cell-highlight-btn-active' : ''
]);
return { ...appState, compactClass, nameClass, highlightBtnClass };
},
template: `
<div
class="planner-grid-left-col agent-cell-root agent-row-left-col cursor-pointer agent-cell-hover-trigger relative-position"
:class="compactClass"
@click="openProfile(agent)"
>
<q-avatar :size="isCompact ? '24px' : '32px'" class="shadow-1">
<img :src="agent.avatar">
</q-avatar>
<div class="q-ml-sm overflow-hidden col">
<div class="text-weight-bold truncate" :class="nameClass">{{ agent.name }}</div>
<div v-if="!isCompact" class="agent-cell-role text-grey-5 uppercase text-weight-bold truncate">{{ agent.role }}</div>
</div>
<q-btn
round
flat
dense
:icon="highlightedRowId === agent.id ? 'visibility_off' : 'visibility'"
:color="highlightedRowId === agent.id ? 'amber-9' : 'grey-4'"
size="sm"
:class="highlightBtnClass"
@click.stop="toggleRowHighlight(agent.id)"
>
<q-tooltip>{{ highlightedRowId === agent.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
</q-btn>
</div>
`
};

View File

@@ -0,0 +1,3 @@
.agent-row-highlighted .agent-row-left-col {
background-color: var(--focus-bg) !important;
}

View File

@@ -0,0 +1,33 @@
import AgentCell from '../agent-cell/agent-cell.js';
import DayCell from '../day-cell/day-cell.js';
const { inject, computed } = Vue;
export default {
name: 'AgentRow',
components: { AgentCell, DayCell },
props: {
item: { type: Object, required: true }
},
setup(props) {
const appState = inject('appState');
const rowClasses = computed(() => [
'planner-grid-row',
'planner-grid-row-item',
'agent-row-root',
appState.highlightedRowId.value === props.item.data.id ? 'agent-row-highlighted' : '',
appState.crosshairActive.value ? 'planner-grid-crosshair-enabled' : ''
]);
return { ...appState, rowClasses };
},
template: `
<div :class="rowClasses">
<agent-cell :agent="item.data"></agent-cell>
<div class="planner-grid-cells-area">
<day-cell v-for="(date, i) in dates" :key="'c' + item.data.id + i" :agent="item.data" :date="date"></day-cell>
</div>
</div>
`
};

View File

@@ -0,0 +1,22 @@
const { inject } = Vue;
export default {
name: 'AgentSummary',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="agent-summary-root row items-center q-mb-xl">
<q-avatar size="64px" class="shadow-1"><img :src="selectedAgent.avatar"></q-avatar>
<div class="q-ml-md">
<div class="text-h6 text-weight-bold">{{ selectedAgent.name }}</div>
<div class="text-caption text-grey-7">{{ selectedAgent.dept }} • {{ selectedAgent.role }}</div>
<div class="text-caption text-indigo-8 text-weight-bold q-mt-xs">
<q-icon name="public" size="xs" class="q-mr-xs"></q-icon>
{{ selectedAgent.hubName }}
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,8 @@
.app-header-root {
background: var(--surface);
color: var(--text-strong);
}
.app-header-border {
border-bottom: 1px solid var(--grid-line);
}

View File

@@ -0,0 +1,23 @@
import HeaderBrand from '../header-brand/header-brand.js';
import HeaderActions from '../header-actions/header-actions.js';
const { inject } = Vue;
export default {
name: 'AppHeader',
components: { HeaderBrand, HeaderActions },
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<q-header class="app-header-root app-header-border">
<q-toolbar class="app-header-toolbar q-py-sm">
<q-btn flat round dense icon="menu" color="grey-7" @click="toggleLeftDrawer"></q-btn>
<header-brand></header-brand>
<q-space></q-space>
<header-actions></header-actions>
</q-toolbar>
</q-header>
`
};

View File

@@ -0,0 +1,97 @@
.app-shell-root {
--left-col-width: 232px;
--cell-width: 96px;
--primary-soft: #f7f8fa;
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f3f4f6;
--surface-3: #eef1f4;
--border-color: #e5e7eb;
--grid-line: #e5e7eb;
--text-strong: #111827;
--text-muted: #6b7280;
--text-faint: #94a3b8;
--highlight-bg: #eef2f7;
--highlight-border: #cbd5e1;
--focus-bg: #e8edf4;
--focus-strong: #dde3ea;
--status-warning: #b45309;
--status-danger: #b91c1c;
}
@media (max-width: 599px) {
.app-shell-root {
--left-col-width: 160px;
}
}
.app-shell-html,
.app-shell-body {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: var(--bg);
color: var(--text-strong);
font-family: "Roboto", "Helvetica Neue", Arial, sans-serif;
font-variant-numeric: tabular-nums;
}
.app-shell-root .shadow-1,
.app-shell-root .shadow-2,
.app-shell-root .shadow-12 {
box-shadow: none !important;
}
.app-shell-root .q-card {
box-shadow: none;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.app-shell-root .q-separator {
background: var(--grid-line);
}
.app-shell-root .q-menu,
.app-shell-root .q-tooltip {
border-radius: 6px;
}
.app-shell-root .text-indigo-10,
.app-shell-root .text-indigo-9,
.app-shell-root .text-indigo-8,
.app-shell-root .text-indigo-4,
.app-shell-root .text-blue-6 {
color: var(--text-strong) !important;
}
.app-shell-root .text-grey-6,
.app-shell-root .text-grey-8,
.app-shell-root .text-grey-7,
.app-shell-root .text-grey-5 {
color: var(--text-muted) !important;
}
.app-shell-root .text-orange-9,
.app-shell-root .text-orange-4 {
color: var(--status-warning) !important;
}
.app-shell-root .text-amber-9 {
color: var(--text-strong) !important;
}
.app-shell-root .text-red-5,
.app-shell-root .text-red-6,
.app-shell-root .text-red-7 {
color: var(--status-danger) !important;
}
.app-shell-root .bg-grey-1 {
background-color: var(--surface-2) !important;
}
.app-shell-root .bg-white {
background-color: var(--surface) !important;
}

View File

@@ -0,0 +1,507 @@
import AppHeader from '../app-header/app-header.js';
import LeftDrawer from '../left-drawer/left-drawer.js';
import FilterDrawer from '../filter-drawer/filter-drawer.js';
import DetailDrawer from '../detail-drawer/detail-drawer.js';
import PlannerPage from '../planner-page/planner-page.js';
import {
HUBS,
DEPARTMENTS,
ROLES,
SKILLS,
SHIFTS,
agents,
loading,
lockedCells,
assignments,
comments,
notes,
holidays,
specialDays,
loadDataFromDatabase,
simulateWssLock,
onlineUsers,
startOnlineUsersSimulation
} from '../../services/socket-service.js';
const { ref, computed, nextTick, onMounted, onUnmounted, provide } = Vue;
const { useQuasar } = Quasar;
export default {
name: 'AppShell',
components: {
AppHeader,
LeftDrawer,
FilterDrawer,
DetailDrawer,
PlannerPage
},
setup() {
const $q = useQuasar();
const leftDrawer = ref(false);
const filterDrawer = ref(false);
const rightDrawer = ref(false);
const editMode = ref('assignment');
const viewport = ref(null);
const isCompact = ref(false);
const weekendsAreWorkingDays = ref(false);
const viewScope = ref(8);
const pickerStartDay = ref(1);
const search = ref('');
const activeDept = ref('All');
const activeHub = ref('All');
const filterRoles = ref([]);
const filterSkills = ref([]);
const groupingSelection = ref(['hub', 'dept']);
const groupingOptions = [
{ label: 'Hub > Division (Default)', value: ['hub', 'dept'] },
{ label: 'Division > Role', value: ['dept', 'role'] },
{ label: 'Hub > Role', value: ['hub', 'role'] },
{ label: 'Division (Flat)', value: ['dept'] },
{ label: 'Role (Flat)', value: ['role'] },
{ label: 'No Grouping (Flat)', value: [] }
];
const hubOptions = [
{ label: 'All Hubs', value: 'All' },
...HUBS.map(h => ({ label: h.name, value: h.id }))
];
const showEodTargets = ref(false);
const showAvailability = ref(false);
const selectedAgent = ref(null);
const selectedDate = ref(null);
const pendingShift = ref(null);
const dateMenu = ref(false);
const proxyDate = ref(null);
const highlightedRowId = ref(null);
const highlightedDateStr = ref(null);
const crosshairActive = ref(true);
const hoveredDateStr = ref(null);
const filterByAvailability = ref(false);
const formatDateForId = (date) => {
if (!date) return '';
const d = new Date(date);
const month = '' + (d.getMonth() + 1);
const day = '' + d.getDate();
const year = d.getFullYear();
return [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');
};
const parseDateId = (dateStr) => {
if (!dateStr || typeof dateStr !== 'string') return null;
const normalized = dateStr.replace(/\//g, '-');
const [y, m, d] = normalized.split('-').map(Number);
if (!y || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null;
const parsed = new Date(y, (m || 1) - 1, d || 1);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const filterDate = ref(formatDateForId(new Date()));
const proxyFilterDate = ref('');
const filterShiftTypes = ref([]);
const getStartOfWeek = (date) => {
const d = new Date(date);
const day = d.getDay();
const diff = (day < 1 ? 7 : 0) + day - 1;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
};
const startDate = ref(getStartOfWeek(new Date()));
const isWeekend = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
const dates = computed(() => {
const res = [];
const now = new Date(startDate.value);
for (let i = 0; i < viewScope.value * 7; i++) {
const d = new Date(now);
d.setDate(now.getDate() + i);
res.push(d);
}
return res;
});
const notify = (payload) => $q.notify(payload);
const simulateWssLockAction = () => {
simulateWssLock(notify);
};
const isCellLocked = (agentId, date) => {
const key = `${agentId}:${formatDateForId(date)}`;
return lockedCells.value.has(key);
};
const getAssignment = (agentId, date) => {
const dateStr = formatDateForId(date);
if (assignments[agentId] && assignments[agentId][dateStr] !== undefined) {
return SHIFTS.find(s => s.id === assignments[agentId][dateStr]);
}
return null;
};
const getHoliday = (date) => holidays[formatDateForId(date)] || null;
const getSpecialDay = (date) => specialDays[formatDateForId(date)] || null;
const hasComment = (agentId, date) => {
const dStr = formatDateForId(date);
return comments[agentId] && comments[agentId][dStr];
};
const hasNote = (agentId, date) => {
const dStr = formatDateForId(date);
return notes[agentId] && notes[agentId][dStr];
};
const getCommentText = (agentId, date) => {
const dStr = formatDateForId(date);
return comments[agentId] ? comments[agentId][dStr] : '';
};
const getNoteText = (agentId, date) => {
const dStr = formatDateForId(date);
return notes[agentId] ? notes[agentId][dStr] : '';
};
const isFilterActive = computed(() =>
filterRoles.value.length > 0 ||
filterSkills.value.length > 0 ||
activeDept.value !== 'All' ||
activeHub.value !== 'All' ||
(search.value && search.value.length > 0) ||
filterByAvailability.value
);
const activeFilterCount = computed(() => {
let count = 0;
if (search.value && search.value.length > 0) count++;
if (activeDept.value !== 'All') count++;
if (activeHub.value !== 'All') count++;
if (filterRoles.value.length > 0) count++;
if (filterSkills.value.length > 0) count++;
if (filterByAvailability.value) count++;
return count;
});
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 = parseDateId(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;
});
});
const flattenedList = computed(() => {
const result = [];
const list = [...filteredAgents.value];
const keys = groupingSelection.value;
const getLabel = (key, value) => {
if (key === 'hub') return HUBS.find(h => h.id === value)?.name || value;
if (key === 'dept') return value.toUpperCase() + ' DIVISION';
if (key === 'role') return value + 's';
return value;
};
if (keys.length === 0) {
return list.map(a => ({ type: 'agent', data: a, id: a.id }));
}
list.sort((a, b) => {
for (const key of keys) {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
}
return 0;
});
const currentValues = keys.map(() => null);
list.forEach(agent => {
let changedLevel = -1;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (agent[key] !== currentValues[i]) {
changedLevel = i;
break;
}
}
if (changedLevel !== -1) {
for (let i = changedLevel; i < keys.length; i++) {
const key = keys[i];
const val = agent[key];
currentValues[i] = val;
const type = i === 0 ? 'header-l1' : 'header-l2';
const path = keys
.slice(0, i + 1)
.map((k, idx) => `${k}:${currentValues[idx]}`)
.join('|');
result.push({
type: type,
label: getLabel(key, val),
id: `hdr-${path}`
});
}
}
result.push({ type: 'agent', data: agent, id: agent.id });
});
return result;
});
const currentAssignmentLabel = computed(() => {
if (pendingShift.value) return pendingShift.value.label;
if (!selectedAgent.value || !selectedDate.value) return null;
const a = getAssignment(selectedAgent.value.id, selectedDate.value);
return a ? a.label : null;
});
const clearFilters = () => {
search.value = '';
activeDept.value = 'All';
activeHub.value = 'All';
filterRoles.value = [];
filterSkills.value = [];
filterByAvailability.value = false;
filterShiftTypes.value = [];
filterDate.value = formatDateForId(new Date());
};
const applySavedFilter = (key) => {
clearFilters();
if (key === 'high_potential') {
filterSkills.value = ['VIP Concierge', 'Technical Training'];
} else if (key === 'remote') {
filterRoles.value = ['Specialist'];
}
};
const syncProxyDate = () => {
proxyDate.value = formatDateForId(selectedDate.value || new Date());
};
const applyDateSelection = () => {
if (!proxyDate.value) return;
const target = parseDateId(proxyDate.value);
if (!target) return;
startDate.value = getStartOfWeek(target);
if (rightDrawer.value && editMode.value === 'assignment' && selectedAgent.value) {
selectedDate.value = target;
pendingShift.value = null;
}
dateMenu.value = false;
nextTick(() => {
if (viewport.value) viewport.value.scrollLeft = 0;
});
};
const resetToToday = () => {
const today = new Date();
startDate.value = getStartOfWeek(today);
if (rightDrawer.value && editMode.value === 'assignment') {
selectedDate.value = today;
pendingShift.value = null;
}
if (viewport.value) viewport.value.scrollLeft = 0;
leftDrawer.value = false;
};
const updateFilterDateProxy = () => {
proxyFilterDate.value = filterDate.value;
};
const applyFilterDate = () => {
filterDate.value = proxyFilterDate.value;
};
const openAssignment = (agent, date) => {
if (isCellLocked(agent.id, date) || (!weekendsAreWorkingDays.value && isWeekend(date))) return;
editMode.value = 'assignment';
selectedAgent.value = agent;
selectedDate.value = date;
pendingShift.value = null;
rightDrawer.value = true;
};
const openProfile = (agent) => {
editMode.value = 'profile';
selectedAgent.value = { ...agent };
selectedDate.value = null;
rightDrawer.value = true;
};
const setPendingShift = (shift) => {
pendingShift.value = shift;
};
const saveAssignment = () => {
if (!selectedAgent.value || !selectedDate.value) return;
const agentId = selectedAgent.value.id;
const dateStr = formatDateForId(selectedDate.value);
if (!assignments[agentId]) assignments[agentId] = {};
assignments[agentId][dateStr] = pendingShift.value ? pendingShift.value.id : null;
pendingShift.value = null;
rightDrawer.value = false;
};
const toggleRowHighlight = (agentId) => {
highlightedRowId.value = highlightedRowId.value === agentId ? null : agentId;
};
const toggleColHighlight = (date) => {
const str = formatDateForId(date);
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
};
const clearHighlights = () => {
highlightedRowId.value = null;
highlightedDateStr.value = null;
};
const hasHighlights = computed(() => Boolean(highlightedRowId.value || highlightedDateStr.value));
const toggleLeftDrawer = () => {
leftDrawer.value = !leftDrawer.value;
};
let stopOnlineUsersSimulation = null;
onMounted(() => {
loadDataFromDatabase(startDate.value, notify);
stopOnlineUsersSimulation = startOnlineUsersSimulation(notify);
});
onUnmounted(() => {
if (stopOnlineUsersSimulation) stopOnlineUsersSimulation();
});
const appState = {
leftDrawer,
filterDrawer,
rightDrawer,
editMode,
viewport,
isCompact,
weekendsAreWorkingDays,
viewScope,
pickerStartDay,
search,
activeDept,
activeHub,
filterRoles,
filterSkills,
onlineUsers,
agents,
loading,
assignments,
comments,
notes,
holidays,
specialDays,
lockedCells,
shifts: SHIFTS,
depts: DEPARTMENTS,
roles: ROLES,
allSkills: SKILLS,
groupingSelection,
groupingOptions,
hubOptions,
showEodTargets,
showAvailability,
selectedAgent,
selectedDate,
pendingShift,
dateMenu,
proxyDate,
highlightedRowId,
highlightedDateStr,
crosshairActive,
hoveredDateStr,
filterByAvailability,
filterDate,
proxyFilterDate,
filterShiftTypes,
dates,
formatDateForId,
isWeekend,
isCellLocked,
getAssignment,
getHoliday,
getSpecialDay,
hasComment,
hasNote,
getCommentText,
getNoteText,
isFilterActive,
activeFilterCount,
filteredAgents,
flattenedList,
currentAssignmentLabel,
clearFilters,
applySavedFilter,
syncProxyDate,
applyDateSelection,
resetToToday,
updateFilterDateProxy,
applyFilterDate,
openAssignment,
openProfile,
setPendingShift,
saveAssignment,
toggleRowHighlight,
toggleColHighlight,
clearHighlights,
hasHighlights,
simulateWssLockAction,
toggleLeftDrawer
};
provide('appState', appState);
return appState;
},
template: `
<div class="app-shell-root">
<q-layout view="hHh Lpr fFf">
<app-header></app-header>
<left-drawer></left-drawer>
<filter-drawer></filter-drawer>
<detail-drawer></detail-drawer>
<planner-page></planner-page>
</q-layout>
</div>
`
};

View File

@@ -0,0 +1,10 @@
.assignment-editor-date-card {
border: 1px solid var(--border-color);
}
.assignment-editor-cell-id {
font-size: 10px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
word-break: break-all;
}

View File

@@ -0,0 +1,47 @@
const { inject } = Vue;
export default {
name: 'AssignmentEditor',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="assignment-editor-root">
<div class="assignment-editor-date-card q-mb-lg q-pa-md bg-blue-grey-1 rounded-borders">
<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="assignment-editor-cell-id bg-grey-1 q-pa-sm rounded-borders">
#cell-{{selectedAgent.id}}-{{formatDateForId(selectedDate)}}
</div>
</div>
`
};

View File

@@ -0,0 +1,10 @@
.availability-row-cell {
width: var(--cell-width);
min-width: var(--cell-width);
height: var(--h-status);
border-right: 1px solid var(--grid-line);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}

View File

@@ -0,0 +1,24 @@
const { inject } = Vue;
export default {
name: 'AvailabilityRow',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div 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="availability-row-cell"
:class="{ 'planner-grid-col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d) }"
>
<q-icon name="circle" :color="i % 10 === 0 ? 'red-3' : (i % 5 === 0 ? 'orange-3' : 'green-3')" size="8px"></q-icon>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,46 @@
export default {
name: 'CellBadges',
props: {
assignment: { type: Object, default: null },
locked: { type: Boolean, default: false },
hasComment: { type: Boolean, default: false },
hasNote: { type: Boolean, default: false },
commentText: { type: String, default: '' },
noteText: { type: String, default: '' }
},
template: `
<div class="cell-badges-root">
<div v-if="locked" class="day-cell-locked-overlay">
<q-icon name="lock" color="blue-grey-3" size="14px">
<q-tooltip class="day-cell-lock-tooltip">This cell is currently being edited by another user.</q-tooltip>
</q-icon>
</div>
<div class="day-cell-icon-row absolute-bottom-right q-pa-xs row no-wrap">
<q-icon
v-if="hasComment"
name="chat_bubble"
size="8px"
color="blue-grey-3"
class="cursor-help day-cell-comment-icon"
>
<q-tooltip class="day-cell-shift-tooltip" anchor="top middle" self="bottom middle">
<div class="text-weight-bold text-caption text-indigo-9 q-mb-xs">User Comment</div>
<div class="text-caption text-grey-8">{{ commentText }}</div>
</q-tooltip>
</q-icon>
<q-icon
v-if="hasNote"
name="info"
size="8px"
color="orange-4"
class="cursor-help day-cell-note-icon"
>
<q-tooltip class="day-cell-shift-tooltip" anchor="top middle" self="bottom middle">
<div class="text-weight-bold text-caption text-orange-9 q-mb-xs">Technical Note</div>
<div class="text-caption text-grey-8">{{ noteText }}</div>
</q-tooltip>
</q-icon>
</div>
</div>
`
};

View File

@@ -0,0 +1,84 @@
.date-cell-root {
width: var(--cell-width);
min-width: var(--cell-width);
height: var(--h-dates);
border-right: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.date-cell-weekend {
background-color: var(--surface-2) !important;
}
.date-cell-weekday {
font-size: 10px;
text-transform: uppercase;
}
.date-cell-date {
line-height: 1.1;
}
.date-cell-text-sat {
color: var(--text-muted) !important;
}
.date-cell-text-sun {
color: var(--text-muted) !important;
}
.date-cell-text-default {
color: var(--text-muted);
}
.date-cell-text-strong {
color: var(--text-strong);
}
.date-cell-reading-active {
background-color: var(--focus-bg) !important;
border-bottom: 2px solid var(--highlight-border);
}
.date-cell-highlight-btn {
position: absolute;
bottom: 6px;
right: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.date-cell-highlight-btn-active {
opacity: 1;
}
.date-cell-hover-trigger:hover .date-cell-highlight-btn {
opacity: 1;
}
.date-cell-highlight-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--status-warning);
}
.date-cell-tooltip-holiday {
background: #7f1d1d;
color: #ffffff;
box-shadow: none;
padding: 6px 8px;
}
.date-cell-tooltip-event {
background: #1f2937;
color: #ffffff;
box-shadow: none;
padding: 6px 8px;
}

View File

@@ -0,0 +1,108 @@
const { inject, computed } = Vue;
export default {
name: 'DateCell',
props: {
date: { type: Date, required: true }
},
setup(props) {
const appState = inject('appState');
const dateId = computed(() => appState.formatDateForId(props.date));
const isWeekendDay = computed(() => appState.isWeekend(props.date));
const weekdayClass = computed(() => {
if (isWeekendDay.value && !appState.weekendsAreWorkingDays.value) {
return props.date.getDay() === 6 ? 'date-cell-text-sat' : 'date-cell-text-sun';
}
return 'date-cell-text-default';
});
const dateClass = computed(() => {
if (isWeekendDay.value && !appState.weekendsAreWorkingDays.value) {
return props.date.getDay() === 6 ? 'date-cell-text-sat' : 'date-cell-text-sun';
}
return 'date-cell-text-strong';
});
const containerClasses = computed(() => [
'date-cell-root',
'date-cell-hover-trigger',
isWeekendDay.value && !appState.weekendsAreWorkingDays.value ? 'date-cell-weekend' : '',
appState.highlightedDateStr.value === dateId.value ? 'date-cell-reading-active' : '',
appState.crosshairActive.value && appState.hoveredDateStr.value === dateId.value ? 'planner-grid-col-hovered' : ''
]);
const highlightBtnClasses = computed(() => [
'date-cell-highlight-btn',
appState.highlightedDateStr.value === dateId.value ? 'date-cell-highlight-btn-active' : ''
]);
const toggleHighlight = () => appState.toggleColHighlight(props.date);
const onEnter = () => { appState.hoveredDateStr.value = dateId.value; };
const onLeave = () => { appState.hoveredDateStr.value = null; };
return {
...appState,
dateId,
weekdayClass,
dateClass,
containerClasses,
highlightBtnClasses,
toggleHighlight,
onEnter,
onLeave
};
},
template: `
<div
:class="containerClasses"
@mouseenter="onEnter"
@mouseleave="onLeave"
>
<div class="absolute-top-right q-pa-xs">
<q-icon
v-if="getHoliday(date)"
name="celebration"
size="10px"
color="red-5"
class="cursor-help"
>
<q-tooltip class="date-cell-tooltip-holiday">Holiday: {{ getHoliday(date) }}</q-tooltip>
</q-icon>
<q-icon
v-else-if="getSpecialDay(date)"
name="bookmark"
size="10px"
color="indigo-4"
class="cursor-help"
>
<q-tooltip class="date-cell-tooltip-event">Event: {{ getSpecialDay(date) }}</q-tooltip>
</q-icon>
</div>
<div class="date-cell-weekday text-weight-bold" :class="weekdayClass">
{{ date.toLocaleDateString('en-US', {weekday: 'short'}) }}
</div>
<div class="date-cell-date text-subtitle2 text-weight-bold" :class="dateClass">
{{ date.getDate() }}. {{ date.toLocaleDateString('en-US', { month: 'short' }) }}
</div>
<q-btn
round
flat
dense
:icon="highlightedDateStr === dateId ? 'visibility_off' : 'visibility'"
:color="highlightedDateStr === dateId ? 'amber-9' : 'grey-5'"
size="sm"
:class="highlightBtnClasses"
@click.stop="toggleHighlight"
>
<q-tooltip>{{ highlightedDateStr === dateId ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
</q-btn>
<div v-if="highlightedDateStr === dateId" class="date-cell-highlight-bar"></div>
</div>
`
};

View File

@@ -0,0 +1,23 @@
import GridSearch from '../grid-search/grid-search.js';
import DateCell from '../date-cell/date-cell.js';
const { inject } = Vue;
export default {
name: 'DatesRow',
components: { GridSearch, DateCell },
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="planner-grid-row planner-grid-row-dates">
<div class="planner-grid-left-col">
<grid-search></grid-search>
</div>
<div class="planner-grid-cells-area">
<date-cell v-for="(date, i) in dates" :key="'d' + i" :date="date"></date-cell>
</div>
</div>
`
};

View File

@@ -0,0 +1,102 @@
.day-cell-root {
width: var(--cell-width);
min-width: var(--cell-width);
height: 58px;
border-right: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.1s ease;
}
.day-cell-hoverable:hover {
background-color: var(--surface-2);
}
.day-cell-compact {
height: 42px;
}
.day-cell-weekend {
background-color: var(--surface-2) !important;
}
.day-cell-reading {
background-color: var(--focus-bg) !important;
}
.day-cell-reading-intersection {
background-color: var(--focus-strong) !important;
}
.day-cell-shift-badge {
font-size: 9px;
font-weight: 800;
padding: 2px 4px;
border-radius: 3px;
width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
border: 1px solid var(--grid-line);
background-color: var(--surface-2);
color: var(--text-strong);
}
.day-cell-shift-badge-green {
border-left: 3px solid #94a3b8;
}
.day-cell-shift-badge-blue {
border-left: 3px solid #7c8796;
}
.day-cell-shift-badge-indigo {
border-left: 3px solid #6b7280;
}
.day-cell-shift-badge-pink {
border-left: 3px solid #9ca3af;
}
.day-cell-locked-overlay {
position: absolute;
inset: 0;
background: rgba(237, 240, 244, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.day-cell-icon-row {
gap: 2px;
}
.day-cell-shift-tooltip {
padding: 10px 12px;
max-width: 220px;
border: 1px solid var(--border-color);
box-shadow: none;
background: var(--surface);
color: var(--text-strong);
}
.day-cell-lock-tooltip {
background: #0f172a;
color: #ffffff;
padding: 8px 10px;
box-shadow: none;
}
.day-cell-comment-icon {
opacity: 0.6;
}
.day-cell-note-icon {
opacity: 0.65;
}

View File

@@ -0,0 +1,84 @@
import CellBadges from '../cell-badges/cell-badges.js';
const { inject, computed } = Vue;
export default {
name: 'DayCell',
components: { CellBadges },
props: {
agent: { type: Object, required: true },
date: { type: Date, required: true }
},
setup(props) {
const appState = inject('appState');
const dateId = computed(() => appState.formatDateForId(props.date));
const isWeekendDay = computed(() => appState.isWeekend(props.date));
const assignment = computed(() => appState.getAssignment(props.agent.id, props.date));
const locked = computed(() => appState.isCellLocked(props.agent.id, props.date));
const cellClasses = computed(() => {
const base = ['day-cell-root'];
if (appState.isCompact.value) base.push('day-cell-compact');
if (isWeekendDay.value && !appState.weekendsAreWorkingDays.value) base.push('day-cell-weekend');
const isBlocked = (isWeekendDay.value && !appState.weekendsAreWorkingDays.value) || locked.value;
if (isBlocked) base.push('cursor-not-allowed');
else base.push('cursor-pointer', 'day-cell-hoverable');
const isRow = appState.highlightedRowId.value === props.agent.id;
const isCol = appState.highlightedDateStr.value === dateId.value;
if (isRow && isCol) base.push('day-cell-reading-intersection');
else if (isRow || isCol) base.push('day-cell-reading');
if (appState.crosshairActive.value && appState.hoveredDateStr.value === dateId.value) {
base.push('planner-grid-col-hovered');
}
return base;
});
const onEnter = () => { appState.hoveredDateStr.value = dateId.value; };
const onLeave = () => { appState.hoveredDateStr.value = null; };
const handleClick = () => {
if (locked.value || (isWeekendDay.value && !appState.weekendsAreWorkingDays.value)) return;
appState.openAssignment(props.agent, props.date);
};
return {
...appState,
cellClasses,
assignment,
locked,
onEnter,
onLeave,
handleClick
};
},
template: `
<div
:class="cellClasses"
@mouseenter="onEnter"
@mouseleave="onLeave"
@click="handleClick"
>
<template v-if="weekendsAreWorkingDays || !isWeekend(date)">
<div
v-if="assignment"
class="day-cell-shift-badge shadow-1"
:class="assignment.badgeClass"
>
{{ assignment.label }}
</div>
</template>
<cell-badges
:assignment="assignment"
:locked="locked"
:has-comment="hasComment(agent.id, date)"
:has-note="hasNote(agent.id, date)"
:comment-text="getCommentText(agent.id, date)"
:note-text="getNoteText(agent.id, date)"
></cell-badges>
</div>
`
};

View File

@@ -0,0 +1,8 @@
.detail-drawer-toolbar {
height: 64px;
border-bottom: 1px solid var(--border-color);
}
.detail-drawer-footer {
border-top: 1px solid var(--border-color);
}

View File

@@ -0,0 +1,33 @@
import AgentSummary from '../agent-summary/agent-summary.js';
import AssignmentEditor from '../assignment-editor/assignment-editor.js';
const { inject } = Vue;
export default {
name: 'DetailDrawer',
components: { AgentSummary, AssignmentEditor },
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<q-drawer v-model="rightDrawer" side="right" bordered :width="380" overlay elevated>
<div class="detail-drawer-root column full-height bg-white">
<q-toolbar class="detail-drawer-toolbar q-px-md">
<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">
<agent-summary v-if="selectedAgent"></agent-summary>
<assignment-editor v-if="editMode === 'assignment' && selectedDate"></assignment-editor>
</q-scroll-area>
<div class="detail-drawer-footer q-pa-lg 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,25 @@
const { inject } = Vue;
export default {
name: 'DevTools',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="dev-tools-root">
<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="simulateWssLockAction"
>
<q-tooltip>Simulate receiving a "Lock Cell" message from server</q-tooltip>
</q-btn>
</div>
`
};

View File

@@ -0,0 +1,21 @@
.eod-row-cell {
width: var(--cell-width);
min-width: var(--cell-width);
height: var(--h-eod);
border-right: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.eod-row-text {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
}
.eod-row-progress {
width: 60%;
}

View File

@@ -0,0 +1,25 @@
const { inject } = Vue;
export default {
name: 'EodRow',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div 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="eod-row-cell"
:class="{ 'planner-grid-col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d) }"
>
<div class="eod-row-text">94%</div>
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded class="eod-row-progress"></q-linear-progress>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,3 @@
.filter-availability-actions {
border-top: 1px solid var(--border-color);
}

View File

@@ -0,0 +1,49 @@
const { inject } = Vue;
export default {
name: 'FilterAvailability',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-availability-root">
<div class="filter-drawer-section-title q-mt-lg">Shift Availability</div>
<div class="filter-drawer-card bg-white q-pa-sm rounded-borders q-mb-md">
<q-toggle v-model="filterByAvailability" label="Filter by Active Shift" dense class="q-mb-sm text-weight-medium text-caption" color="green-6"></q-toggle>
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-xs animated fadeIn">
<q-input v-model="filterDate" filled dense label="Target Date" class="text-caption">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale" @before-show="updateFilterDateProxy">
<div class="column bg-white">
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal color="indigo-8" :first-day-of-week="pickerStartDay">
<div class="filter-availability-actions row items-center justify-end q-pa-sm q-gutter-sm">
<q-btn v-close-popup label="Cancel" color="grey-7" flat dense></q-btn>
<q-btn v-close-popup label="OK" color="indigo-8" flat dense @click="applyFilterDate"></q-btn>
</div>
</q-date>
</div>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
<div class="text-caption text-grey-7 q-mb-xs">Required Shift Type:</div>
<div class="row q-gutter-xs">
<q-checkbox
v-for="s in shifts"
:key="s.id"
v-model="filterShiftTypes"
:val="s.id"
:label="s.label"
dense
size="xs"
:color="s.color.split('-')[0]"
class="col-12 text-caption"
></q-checkbox>
</div>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,15 @@
const { inject } = Vue;
export default {
name: 'FilterDivision',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-division-root">
<div class="filter-drawer-section-title">Primary Division</div>
<q-select v-model="activeDept" :options="['All', ...depts]" outlined dense rounded bg-color="white"></q-select>
</div>
`
};

View File

@@ -0,0 +1,13 @@
.filter-drawer-section-title {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.1em;
color: var(--text-faint);
margin-top: 24px;
margin-bottom: 12px;
text-transform: uppercase;
}
.filter-drawer-card {
border: 1px solid var(--border-color);
}

View File

@@ -0,0 +1,51 @@
import FilterHeader from '../filter-header/filter-header.js';
import FilterSavedViews from '../filter-saved-views/filter-saved-views.js';
import FilterAvailability from '../filter-availability/filter-availability.js';
import FilterHub from '../filter-hub/filter-hub.js';
import FilterDivision from '../filter-division/filter-division.js';
import FilterRoles from '../filter-roles/filter-roles.js';
import FilterSkills from '../filter-skills/filter-skills.js';
const { inject } = Vue;
export default {
name: 'FilterDrawer',
components: {
FilterHeader,
FilterSavedViews,
FilterAvailability,
FilterHub,
FilterDivision,
FilterRoles,
FilterSkills
},
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<q-drawer v-model="filterDrawer" side="right" bordered :width="300" class="filter-drawer-root bg-grey-1">
<q-scroll-area class="fit">
<div class="filter-drawer-content q-pa-lg">
<filter-header></filter-header>
<q-btn
flat
rounded
color="red-5"
label="Clear All Filters"
class="filter-drawer-clear-btn full-width q-mb-lg"
size="sm"
icon="filter_alt_off"
@click="clearFilters"
></q-btn>
<filter-saved-views></filter-saved-views>
<filter-availability></filter-availability>
<filter-hub></filter-hub>
<filter-division></filter-division>
<filter-roles></filter-roles>
<filter-skills></filter-skills>
</div>
</q-scroll-area>
</q-drawer>
`
};

View File

@@ -0,0 +1,18 @@
const { inject } = Vue;
export default {
name: 'FilterHeader',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-header-root">
<div class="row items-center justify-between q-mb-xs">
<div class="text-h6 text-weight-bold text-indigo-10">Filters</div>
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="filterDrawer = false"></q-btn>
</div>
<div class="text-caption text-grey-6 q-mb-sm">Refine the agent list by skills and expertise.</div>
</div>
`
};

View File

@@ -0,0 +1,15 @@
const { inject } = Vue;
export default {
name: 'FilterHub',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-hub-root">
<div class="filter-drawer-section-title">Hub Location</div>
<q-select v-model="activeHub" :options="hubOptions" emit-value map-options outlined dense rounded bg-color="white"></q-select>
</div>
`
};

View File

@@ -0,0 +1,15 @@
const { inject } = Vue;
export default {
name: 'FilterRoles',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-roles-root">
<div class="filter-drawer-section-title">Roles</div>
<q-select v-model="filterRoles" :options="roles" multiple outlined dense rounded use-chips bg-color="white" placeholder="Filter by role..."></q-select>
</div>
`
};

View File

@@ -0,0 +1,28 @@
const { inject } = Vue;
export default {
name: 'FilterSavedViews',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-saved-views-root">
<div class="filter-drawer-section-title">Saved Views</div>
<q-list dense class="q-gutter-y-xs">
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('all')">
<q-item-section avatar><q-icon name="people_outline" size="xs"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">All Agents</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('high_potential')">
<q-item-section avatar><q-icon name="star_outline" size="xs" color="amber-8"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">High Expertise</q-item-section>
</q-item>
<q-item clickable v-ripple rounded class="rounded-borders" @click="applySavedFilter('remote')">
<q-item-section avatar><q-icon name="house_siding" size="xs" color="blue-6"></q-icon></q-item-section>
<q-item-section class="text-weight-medium">Remote Support</q-item-section>
</q-item>
</q-list>
</div>
`
};

View File

@@ -0,0 +1,15 @@
const { inject } = Vue;
export default {
name: 'FilterSkills',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="filter-skills-root">
<div class="filter-drawer-section-title">Skills & Expertise</div>
<q-select v-model="filterSkills" :options="allSkills" multiple outlined dense rounded use-chips bg-color="white" placeholder="Select expertise..."></q-select>
</div>
`
};

View File

@@ -0,0 +1,33 @@
.grid-search-container {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.grid-search-filter-btn-active {
background-color: var(--surface-2) !important;
border: 1px solid var(--border-color) !important;
}
.grid-search-filter-status-dot {
width: 8px;
height: 8px;
background-color: var(--status-warning);
border-radius: 50%;
position: absolute;
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px var(--surface);
z-index: 10;
}
.grid-search-dense-date-card {
width: 290px;
border: 1px solid var(--border-color);
}
.grid-search-date {
width: 100%;
min-height: unset;
}

View File

@@ -0,0 +1,56 @@
const { inject } = Vue;
const { useQuasar } = Quasar;
export default {
name: 'GridSearch',
setup() {
const appState = inject('appState');
const $q = useQuasar();
return { ...appState, $q };
},
template: `
<div class="grid-search-container">
<q-input
v-model="search"
debounce="300"
dense
outlined
rounded
:placeholder="$q.screen.lt.sm ? 'Search' : 'Quick search...'"
class="full-width"
bg-color="white"
clearable
>
<template v-slot:prepend>
<q-icon name="search" size="xs" color="grey-5"></q-icon>
</template>
</q-input>
<q-btn
flat
round
dense
icon="filter_alt"
:color="isFilterActive ? 'indigo-10' : 'grey-6'"
:class="isFilterActive ? 'grid-search-filter-btn-active' : ''"
size="sm"
@click="filterDrawer = !filterDrawer"
>
<div v-if="isFilterActive" class="grid-search-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="grid-search-dense-date-card shadow-12">
<q-card-section class="q-pa-none">
<q-date v-model="proxyDate" mask="YYYY-MM-DD" minimal flat color="indigo-8" :first-day-of-week="pickerStartDay" class="grid-search-date"></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>
`
};

View File

@@ -0,0 +1,50 @@
const { inject } = Vue;
export default {
name: 'GridToggles',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="grid-toggles-root">
<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>
<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>
</div>
`
};

View File

@@ -0,0 +1,44 @@
.group-header-row-l1 {
background: var(--surface-3);
font-size: 12px;
font-weight: 800;
color: var(--text-strong);
letter-spacing: 0.04em;
height: 36px;
border-top: 1px solid var(--grid-line);
border-bottom: 1px solid var(--grid-line);
}
.group-header-row-l1 .group-header-row-left-col {
background: var(--surface-3);
color: var(--text-strong);
}
.group-header-row-l2 {
background: var(--surface-2);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.025em;
height: 32px;
border-top: 1px solid var(--grid-line);
}
.group-header-row-l2 .group-header-row-left-col {
background: var(--surface-2);
color: var(--text-muted);
padding-left: 24px;
}
.group-header-row-fill {
flex: 1;
border-right: none;
}
.group-header-row-fill-l1 {
height: 36px;
}
.group-header-row-fill-l2 {
height: 32px;
}

View File

@@ -0,0 +1,22 @@
const { computed } = Vue;
export default {
name: 'GroupHeaderRow',
props: {
item: { type: Object, required: true }
},
setup(props) {
const isL1 = computed(() => props.item.type === 'header-l1');
const rowClass = computed(() => isL1.value ? 'group-header-row-l1' : 'group-header-row-l2');
const fillClass = computed(() => isL1.value ? 'group-header-row-fill-l1' : 'group-header-row-fill-l2');
return { isL1, rowClass, fillClass };
},
template: `
<div :class="['planner-grid-row', rowClass]">
<div class="planner-grid-left-col group-header-row-left-col">{{ item.label }}</div>
<div class="planner-grid-cells-area">
<div :class="['group-header-row-fill', fillClass]"></div>
</div>
</div>
`
};

View File

@@ -0,0 +1,43 @@
import OnlineUsersStack from '../online-users-stack/online-users-stack.js';
import OnlineUsersMenu from '../online-users-menu/online-users-menu.js';
const { inject } = Vue;
const { useQuasar } = Quasar;
export default {
name: 'HeaderActions',
components: { OnlineUsersStack, OnlineUsersMenu },
setup() {
const appState = inject('appState');
const $q = useQuasar();
return { ...appState, $q };
},
template: `
<div class="header-actions-root row items-center q-gutter-sm">
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
<q-btn
v-if="hasHighlights"
outline
rounded
dense
color="amber-9"
icon="highlight_off"
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
class="q-mr-xs bg-amber-1"
@click="clearHighlights"
>
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
</q-btn>
</transition>
<online-users-stack class="gt-sm"></online-users-stack>
<online-users-menu class="lt-md"></online-users-menu>
<q-separator vertical inset class="gt-xs q-mx-sm"></q-separator>
<q-btn flat round dense icon="notifications" color="grey-6" class="gt-xs"></q-btn>
<q-avatar size="32px" class="cursor-pointer q-ml-xs">
<img src="https://i.pravatar.cc/150?u=me">
</q-avatar>
</div>
`
};

View File

@@ -0,0 +1,11 @@
.header-brand-icon-box {
background: transparent;
border: 1px solid var(--border-color);
padding: 4px;
border-radius: 3px;
margin-right: 8px;
}
.header-brand-title {
max-width: 180px;
}

View File

@@ -0,0 +1,10 @@
export default {
name: 'HeaderBrand',
template: `
<div class="header-brand-root row items-center q-ml-md">
<q-toolbar-title class="header-brand-title text-weight-bold text-subtitle1 text-indigo-10 ellipsis">
Hotline Planner (codex)
</q-toolbar-title>
</div>
`
};

View File

@@ -0,0 +1,9 @@
.left-drawer-root {
background: var(--surface);
color: var(--text-strong);
}
.left-drawer-content {
padding-top: 18px !important;
padding-bottom: 18px !important;
}

View File

@@ -0,0 +1,34 @@
import TimelineSettings from '../timeline-settings/timeline-settings.js';
import DevTools from '../dev-tools/dev-tools.js';
import GridToggles from '../grid-toggles/grid-toggles.js';
import ResetControls from '../reset-controls/reset-controls.js';
const { inject } = Vue;
export default {
name: 'LeftDrawer',
components: { TimelineSettings, DevTools, GridToggles, ResetControls },
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<q-drawer v-model="leftDrawer" side="left" bordered :width="300" class="left-drawer-root bg-white">
<q-scroll-area class="fit">
<div class="left-drawer-content 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">
<timeline-settings></timeline-settings>
<q-separator></q-separator>
<dev-tools></dev-tools>
<q-separator></q-separator>
<grid-toggles></grid-toggles>
<q-separator></q-separator>
<reset-controls></reset-controls>
</div>
</div>
</q-scroll-area>
</q-drawer>
`
};

View File

@@ -0,0 +1,8 @@
.loading-skeleton-left {
height: 59px;
border-bottom: 1px solid var(--grid-line);
}
.loading-skeleton-fade {
opacity: 0.3;
}

View File

@@ -0,0 +1,24 @@
export default {
name: 'LoadingSkeleton',
template: `
<div class="loading-skeleton-root q-pa-none">
<div v-for="n in 18" :key="n" class="planner-grid-row">
<div class="planner-grid-left-col loading-skeleton-left">
<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="day-cell-root">
<!--<q-skeleton v-if="Math.random() > 0.6" type="rect" height="20px" width="80%" class="rounded-borders loading-skeleton-fade" />-->
<q-skeleton type="rect" height="20px" width="70%" class="rounded-borders" />
</div>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,13 @@
.online-users-menu-menu {
background: var(--surface);
box-shadow: none;
border: 1px solid var(--border-color);
}
.online-users-menu-list {
min-width: 240px;
}
.online-users-menu-scroll {
height: 250px;
}

View File

@@ -0,0 +1,31 @@
const { inject } = Vue;
export default {
name: 'OnlineUsersMenu',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="online-users-menu-root">
<q-btn round flat dense color="grey-6" icon="group">
<q-badge color="red" floating rounded>{{ onlineUsers.length }}</q-badge>
<q-menu class="online-users-menu-menu">
<q-list dense class="online-users-menu-list">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Online Now</q-item-label>
<q-separator></q-separator>
<q-scroll-area class="online-users-menu-scroll">
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
<q-item-section>
<q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label>
<q-item-label caption>{{ user.role }}</q-item-label>
</q-item-section>
</q-item>
</q-scroll-area>
</q-list>
</q-menu>
</q-btn>
</div>
`
};

View File

@@ -0,0 +1,57 @@
.online-users-stack-item {
margin-left: -8px;
border: 1px solid var(--border-color);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: none;
}
.online-users-stack-img:hover {
transform: translateY(-1px);
z-index: 10;
border-color: var(--highlight-border);
}
.online-users-stack-count-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--surface-2);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 5;
border: 1px solid var(--border-color);
}
.online-users-stack-profile-card {
min-width: 260px;
padding: 16px;
}
.online-users-stack-tooltip {
background: var(--surface);
color: var(--text-strong);
box-shadow: none;
padding: 0;
border-radius: 6px;
border: 1px solid var(--border-color);
}
.online-users-stack-menu {
background: var(--surface);
box-shadow: none;
border: 1px solid var(--border-color);
}
.online-users-stack-list {
min-width: 240px;
}
.online-users-stack-scroll {
max-height: 250px;
overflow-y: auto;
}

View File

@@ -0,0 +1,62 @@
const { inject, computed } = Vue;
const MAX_VISIBLE = 5;
export default {
name: 'OnlineUsersStack',
setup() {
const appState = inject('appState');
const remainingOnlineCount = computed(() => Math.max(0, appState.onlineUsers.value.length - MAX_VISIBLE));
const visibleOnlineUsers = computed(() => appState.onlineUsers.value.slice(0, MAX_VISIBLE));
const additionalOnlineUsers = computed(() => appState.onlineUsers.value.slice(MAX_VISIBLE));
return { ...appState, remainingOnlineCount, visibleOnlineUsers, additionalOnlineUsers };
},
template: `
<div class="row items-center gt-sm q-mr-sm">
<div class="row items-center no-wrap">
<q-avatar
v-for="user in visibleOnlineUsers"
:key="user.id"
size="32px"
class="online-users-stack-item online-users-stack-img"
>
<img :src="user.img">
<q-tooltip
anchor="bottom middle"
self="top middle"
:offset="[0, 10]"
class="online-users-stack-tooltip"
>
<div class="online-users-stack-profile-card row no-wrap items-center">
<q-avatar size="54px" class="q-mr-md shadow-1"><img :src="user.img"></q-avatar>
<div class="column">
<div class="text-weight-bold text-body2">{{ user.name }}</div>
<div class="text-caption text-grey-6">{{ user.role }}</div>
<div class="text-caption text-blue-6 text-weight-medium">Online Now</div>
</div>
</div>
</q-tooltip>
</q-avatar>
<div v-if="remainingOnlineCount > 0" class="online-users-stack-item online-users-stack-count-circle cursor-pointer">
+{{ remainingOnlineCount }}
<q-menu class="online-users-stack-menu">
<q-list dense class="online-users-stack-list">
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Active Collaborators</q-item-label>
<q-separator></q-separator>
<div class="online-users-stack-scroll">
<q-item v-for="user in additionalOnlineUsers" :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>
</div>
</q-list>
</q-menu>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,133 @@
.planner-grid-viewport {
height: calc(100vh - 64px) !important;
overflow: auto !important;
background: var(--surface);
position: relative;
display: block;
border-top: 1px solid var(--grid-line);
--h-eod: 0px;
--h-status: 0px;
--h-dates: 52px;
}
.planner-grid-eod-on {
--h-eod: 42px;
}
.planner-grid-availability-on {
--h-status: 34px;
}
.planner-grid-viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.planner-grid-viewport::-webkit-scrollbar-track {
background: var(--surface-2);
}
.planner-grid-viewport::-webkit-scrollbar-thumb {
background-color: #cfd6df;
border-radius: 20px;
border: 2px solid var(--surface-2);
}
.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(--grid-line);
}
.planner-grid-left-col {
width: var(--left-col-width);
min-width: var(--left-col-width);
position: sticky;
left: 0;
z-index: 20;
background: var(--surface);
border-right: 1px solid var(--grid-line);
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
}
.planner-grid-row-eod {
position: sticky;
top: 0;
z-index: 40;
height: var(--h-eod);
background: var(--surface);
}
.planner-grid-row-status {
position: sticky;
top: var(--h-eod);
z-index: 39;
height: var(--h-status);
background: var(--surface);
}
.planner-grid-row-dates {
position: sticky;
top: calc(var(--h-eod) + var(--h-status));
z-index: 38;
height: var(--h-dates);
background: var(--surface);
}
.planner-grid-row-eod .planner-grid-left-col {
z-index: 60;
background: var(--surface);
color: var(--text-strong);
border-bottom: 1px solid var(--grid-line);
}
.planner-grid-row-status .planner-grid-left-col {
z-index: 59;
background: var(--surface);
color: var(--text-muted);
}
.planner-grid-row-dates .planner-grid-left-col {
z-index: 58;
background: var(--surface);
padding-right: 12px;
border-bottom: 1px solid var(--grid-line);
}
.planner-grid-cells-area {
display: flex;
flex-direction: row;
}
.planner-grid-col-hovered {
background-color: var(--highlight-bg) !important;
box-shadow: inset 0 0 0 1px var(--highlight-border);
z-index: 1;
}
.planner-grid-row-item.planner-grid-crosshair-enabled:hover {
background-color: var(--highlight-bg);
box-shadow: inset 0 0 0 1px var(--highlight-border);
}
.planner-grid-row-item.planner-grid-crosshair-enabled:hover .planner-grid-left-col {
background-color: var(--focus-bg) !important;
border-top: 1px solid var(--highlight-border);
border-bottom: 1px solid var(--highlight-border);
color: var(--text-strong);
}
@media (max-width: 599px) {
.planner-grid-left-col {
padding: 0 8px !important;
}
}

View File

@@ -0,0 +1,63 @@
import EodRow from '../eod-row/eod-row.js';
import AvailabilityRow from '../availability-row/availability-row.js';
import DatesRow from '../dates-row/dates-row.js';
import LoadingSkeleton from '../loading-skeleton/loading-skeleton.js';
import GroupHeaderRow from '../group-header-row/group-header-row.js';
import AgentRow from '../agent-row/agent-row.js';
const { inject, computed } = Vue;
export default {
name: 'PlannerGrid',
components: {
EodRow,
AvailabilityRow,
DatesRow,
LoadingSkeleton,
GroupHeaderRow,
AgentRow
},
setup() {
const appState = inject('appState');
const gridClass = computed(() => ({
'planner-grid-viewport': true,
'planner-grid-eod-on': appState.showEodTargets.value,
'planner-grid-eod-off': !appState.showEodTargets.value,
'planner-grid-availability-on': appState.showAvailability.value,
'planner-grid-availability-off': !appState.showAvailability.value
}));
return { ...appState, gridClass };
},
template: `
<div
id="viewport-target"
:class="gridClass"
ref="viewport"
>
<div class="planner-grid-content">
<eod-row v-if="showEodTargets"></eod-row>
<availability-row v-if="showAvailability"></availability-row>
<dates-row></dates-row>
<loading-skeleton v-if="loading"></loading-skeleton>
<q-virtual-scroll
v-else
scroll-target="#viewport-target"
:items="flattenedList"
:item-size="isCompact ? 43 : 59"
class="planner-grid-virtual-scroll"
>
<template v-slot="{ item }">
<group-header-row
v-if="item.type === 'header-l1' || item.type === 'header-l2'"
:key="item.id"
:item="item"
></group-header-row>
<agent-row v-else :key="item.id" :item="item"></agent-row>
</template>
</q-virtual-scroll>
</div>
</div>
`
};

View File

@@ -0,0 +1,4 @@
.planner-page-root {
background: var(--bg);
color: var(--text-strong);
}

View File

@@ -0,0 +1,23 @@
import PlannerGrid from '../planner-grid/planner-grid.js';
const { inject } = Vue;
export default {
name: 'PlannerPage',
components: { PlannerGrid },
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<q-page-container>
<q-page class="planner-page-root q-pa-none">
<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>
<planner-grid></planner-grid>
</q-page>
</q-page-container>
`
};

View File

@@ -0,0 +1,22 @@
const { inject } = Vue;
export default {
name: 'ResetControls',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="reset-controls-root">
<q-btn
outline
rounded
color="indigo-8"
label="Reset to Today"
icon="today"
class="full-width q-mt-md"
@click="resetToToday"
></q-btn>
</div>
`
};

View File

@@ -0,0 +1,36 @@
const { inject } = Vue;
export default {
name: 'TimelineSettings',
setup() {
const appState = inject('appState');
return { ...appState };
},
template: `
<div class="timeline-settings-root">
<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 class="text-overline text-grey-6 q-mt-md 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>
`
};

View File

@@ -0,0 +1,11 @@
import AppShell from './components/app-shell/app-shell.js';
const { createApp } = Vue;
const app = createApp(AppShell);
app.use(Quasar, { config: { brand: { primary: '#334155' } } });
document.documentElement.classList.add('app-shell-html');
document.body.classList.add('app-shell-body');
app.mount('#q-app');

View File

@@ -0,0 +1,284 @@
const { ref, reactive } = Vue;
const HUBS = [
{ id: 'DE', name: 'GERMANY HUB (DE)' },
{ id: 'IT', name: 'ITALY HUB (IT)' },
{ id: 'FR', name: 'FRANCE HUB (FR)' },
{ id: 'GB', name: 'UNITED KINGDOM (GB)' },
{ id: 'ES', name: 'SPAIN HUB (ES)' },
{ id: 'AE', name: 'UAE HUB (AE)' },
{ id: 'PL', name: 'POLAND HUB (PL)' }
];
const DEPARTMENTS = ['Support', 'Technical', 'Sales', 'VIP', 'Billing'];
const ROLES = ['Senior Lead', 'Specialist', 'Agent'];
const SKILLS = [
'English',
'German',
'French',
'Molecular App',
'Hardware',
'Billing Specialist',
'VIP Concierge',
'Technical Training'
];
const SHIFTS = [
{ id: 'm', label: 'MORNING', color: 'green-7', badgeClass: 'day-cell-shift-badge-green' },
{ id: 'a', label: 'AFTERNOON', color: 'blue-7', badgeClass: 'day-cell-shift-badge-blue' },
{ id: 'h', label: 'HOTLINE', color: 'indigo-7', badgeClass: 'day-cell-shift-badge-indigo' },
{ id: 'e', label: 'EOD ONLY', color: 'pink-7', badgeClass: 'day-cell-shift-badge-pink' }
];
const agents = ref([]);
const loading = ref(false);
const lockedCells = ref(new Set());
const onlineUsers = ref([]);
const assignments = reactive({});
const comments = reactive({});
const notes = reactive({});
const holidays = reactive({});
const specialDays = reactive({});
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.'
];
const ONLINE_USER_ROLES = ['Planner', 'Scheduler', 'Lead', 'Manager'];
const ONLINE_USERS_POOL = Array.from({ length: 28 }, (_, i) => {
const pravatarId = (i % 70) + 1;
return {
id: i + 1,
name: `User ${i + 1}`,
role: ONLINE_USER_ROLES[i % ONLINE_USER_ROLES.length],
img: `https://i.pravatar.cc/150?img=${pravatarId}`
};
});
const formatDateForId = (date) => {
if (!date) return '';
const d = new Date(date);
const month = '' + (d.getMonth() + 1);
const day = '' + d.getDate();
const year = d.getFullYear();
return [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');
};
const generateMockAgents = (count) => {
return Array.from({ length: count }, (_, i) => {
const hub = HUBS[Math.floor(Math.random() * HUBS.length)];
return {
id: i + 1,
name: `Agent ${i + 1}`,
dept: DEPARTMENTS[i % DEPARTMENTS.length],
role: ROLES[i % ROLES.length],
hub: hub.id,
hubName: hub.name,
skills: [SKILLS[i % SKILLS.length], SKILLS[(i + 2) % SKILLS.length]],
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=Agent${i}`
};
});
};
const loadDataFromDatabase = (startDate, notify) => {
loading.value = true;
setTimeout(() => {
const fetchedAgents = generateMockAgents(800);
agents.value = fetchedAgents;
const mockStartDate = new Date(startDate);
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];
}
});
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;
if (notify) {
notify({ message: 'Data Loaded: 800 Agents', color: 'positive', position: 'top', timeout: 1000 });
}
}, 2000);
};
const handleWssMessage = (type, payload, notify) => {
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';
if (notify) {
notify({
message: `WSS: Cell Locked for ${name} on ${payload.date}`,
color: 'negative',
position: 'top',
icon: 'lock'
});
}
}
};
const simulateWssLock = (notify) => {
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 }, notify);
};
const selectRandomItems = (list, count) => {
const pool = [...list];
const selected = [];
while (pool.length > 0 && selected.length < count) {
const idx = Math.floor(Math.random() * pool.length);
selected.push(pool.splice(idx, 1)[0]);
}
return selected;
};
const seedOnlineUsers = (count = 7) => {
onlineUsers.value = selectRandomItems(ONLINE_USERS_POOL, count);
};
const simulateWssOnlineUsersChange = (notify) => {
if (onlineUsers.value.length === 0) {
seedOnlineUsers(3);
return;
}
const maxOnline = 18;
const minOnline = 1;
const shouldAdd = Math.random() > 0.45;
const current = [...onlineUsers.value];
if (shouldAdd && current.length < maxOnline) {
const available = ONLINE_USERS_POOL.filter(
user => !current.some(existing => existing.id === user.id)
);
if (available.length === 0) return;
const joiner = available[Math.floor(Math.random() * available.length)];
onlineUsers.value = [...current, joiner];
if (notify) {
notify({
message: `WSS: ${joiner.name} joined`,
color: 'grey-8',
textColor: 'white',
position: 'bottom-right',
timeout: 700,
icon: 'group_add'
});
}
return;
}
if (current.length > minOnline) {
const leaverIndex = Math.floor(Math.random() * current.length);
const [leaver] = current.splice(leaverIndex, 1);
onlineUsers.value = current;
if (notify) {
notify({
message: `WSS: ${leaver.name} left`,
color: 'grey-8',
textColor: 'white',
position: 'bottom-right',
timeout: 700,
icon: 'person_remove'
});
}
}
};
const startOnlineUsersSimulation = (notify, options = {}) => {
const minDelay = options.minDelay ?? 1800;
const maxDelay = options.maxDelay ?? 5200;
let timeoutId = null;
let active = true;
if (onlineUsers.value.length === 0) {
seedOnlineUsers(options.seedCount ?? 7);
}
const scheduleNext = () => {
if (!active) return;
const delay = Math.floor(minDelay + Math.random() * (maxDelay - minDelay));
timeoutId = setTimeout(() => {
simulateWssOnlineUsersChange(notify);
scheduleNext();
}, delay);
};
scheduleNext();
return () => {
active = false;
if (timeoutId) clearTimeout(timeoutId);
};
};
export {
HUBS,
DEPARTMENTS,
ROLES,
SKILLS,
SHIFTS,
agents,
loading,
lockedCells,
onlineUsers,
assignments,
comments,
notes,
holidays,
specialDays,
loadDataFromDatabase,
handleWssMessage,
simulateWssLock,
seedOnlineUsers,
simulateWssOnlineUsersChange,
startOnlineUsersSimulation
};

View File

@@ -0,0 +1,15 @@
#!/bin/bash
PORT=5183
cd "$(dirname "$0")"
# Cleanup existing process on the specified port
lsof -ti:$PORT | xargs kill -9 2>/dev/null
echo "Starting Hotline Planner on port $PORT..."
python3 -m http.server $PORT &
sleep 1
open "http://localhost:$PORT"
echo "Server active. Press Ctrl+C to stop."
wait

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="src/saas-theme.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="src/components/app-shell/app-shell.css">
<link rel="stylesheet" href="src/components/app-header/app-header.css">
<link rel="stylesheet" href="src/components/header-brand/header-brand.css">
<link rel="stylesheet" href="src/components/header-actions/header-actions.css">
<link rel="stylesheet" href="src/components/online-users-stack/online-users-stack.css">
<link rel="stylesheet" href="src/components/online-users-menu/online-users-menu.css">
<link rel="stylesheet" href="src/components/left-drawer/left-drawer.css">
<link rel="stylesheet" href="src/components/timeline-settings/timeline-settings.css">
<link rel="stylesheet" href="src/components/dev-tools/dev-tools.css">
<link rel="stylesheet" href="src/components/grid-toggles/grid-toggles.css">
<link rel="stylesheet" href="src/components/reset-controls/reset-controls.css">
<link rel="stylesheet" href="src/components/filter-drawer/filter-drawer.css">
<link rel="stylesheet" href="src/components/filter-header/filter-header.css">
<link rel="stylesheet" href="src/components/filter-saved-views/filter-saved-views.css">
<link rel="stylesheet" href="src/components/filter-availability/filter-availability.css">
<link rel="stylesheet" href="src/components/filter-hub/filter-hub.css">
<link rel="stylesheet" href="src/components/filter-division/filter-division.css">
<link rel="stylesheet" href="src/components/filter-roles/filter-roles.css">
<link rel="stylesheet" href="src/components/filter-skills/filter-skills.css">
<link rel="stylesheet" href="src/components/detail-drawer/detail-drawer.css">
<link rel="stylesheet" href="src/components/agent-summary/agent-summary.css">
<link rel="stylesheet" href="src/components/assignment-editor/assignment-editor.css">
<link rel="stylesheet" href="src/components/planner-page/planner-page.css">
<link rel="stylesheet" href="src/components/loading-skeleton/loading-skeleton.css">
<link rel="stylesheet" href="src/components/planner-grid/planner-grid.css">
<link rel="stylesheet" href="src/components/eod-row/eod-row.css">
<link rel="stylesheet" href="src/components/availability-row/availability-row.css">
<link rel="stylesheet" href="src/components/dates-row/dates-row.css">
<link rel="stylesheet" href="src/components/grid-search/grid-search.css">
<link rel="stylesheet" href="src/components/date-cell/date-cell.css">
<link rel="stylesheet" href="src/components/group-header-row/group-header-row.css">
<link rel="stylesheet" href="src/components/agent-row/agent-row.css">
<link rel="stylesheet" href="src/components/agent-cell/agent-cell.css">
<link rel="stylesheet" href="src/components/day-cell/day-cell.css">
<link rel="stylesheet" href="src/components/cell-badges/cell-badges.css">
</head>
<body>
<div id="q-app"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
<script type="module" src="src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
.agent-cell-compact {
height: 42px;
}
.agent-cell-name {
font-size: 13px;
}
.agent-cell-name-compact {
font-size: 11px;
}
.agent-cell-role {
font-size: 10px;
}
.agent-cell-highlight-btn {
opacity: 0;
transition: opacity 0.2s;
}
.agent-cell-highlight-btn-active {
opacity: 1;
}
.agent-cell-hover-trigger:hover .agent-cell-highlight-btn {
opacity: 1;
}
@media (max-width: 599px) {
.agent-cell-role {
display: none;
}
}

View File

@@ -0,0 +1,47 @@
const { inject, computed } = Vue;
export default {
name: 'AgentCell',
props: {
agent: { type: Object, required: true }
},
setup(props) {
const appState = inject('appState');
const compactClass = computed(() => appState.isCompact.value ? 'agent-cell-compact' : '');
const nameClass = computed(() => appState.isCompact.value ? 'agent-cell-name-compact' : 'agent-cell-name');
const highlightBtnClass = computed(() => [
'agent-cell-highlight-btn',
appState.highlightedRowId.value === props.agent.id ? 'agent-cell-highlight-btn-active' : ''
]);
return { ...appState, compactClass, nameClass, highlightBtnClass };
},
template: `
<div
class="planner-grid-left-col agent-cell-root agent-row-left-col cursor-pointer agent-cell-hover-trigger relative-position"
:class="compactClass"
@click="openProfile(agent)"
>
<q-avatar :size="isCompact ? '24px' : '32px'">
<img :src="agent.avatar">
</q-avatar>
<div class="q-ml-sm overflow-hidden col">
<div class="text-weight-bold truncate" :class="nameClass">{{ agent.name }}</div>
<div v-if="!isCompact" class="agent-cell-role text-slate-400 uppercase text-weight-bold truncate">{{ agent.role }}</div>
</div>
<q-btn
round
flat
dense
:icon="highlightedRowId === agent.id ? 'visibility_off' : 'visibility'"
:color="highlightedRowId === agent.id ? 'warning' : 'slate-300'"
size="sm"
:class="highlightBtnClass"
@click.stop="toggleRowHighlight(agent.id)"
>
<q-tooltip>{{ highlightedRowId === agent.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
</q-btn>
</div>
`
};

View File

@@ -0,0 +1,5 @@
.agent-row-highlighted .agent-row-left-col {
background-color: #eff6ff !important;
/* light blue/indigo tint */
box-shadow: inset 2px 0 0 var(--q-primary) !important;
}

View File

@@ -0,0 +1,33 @@
import AgentCell from '../agent-cell/agent-cell.js';
import DayCell from '../day-cell/day-cell.js';
const { inject, computed } = Vue;
export default {
name: 'AgentRow',
components: { AgentCell, DayCell },
props: {
item: { type: Object, required: true }
},
setup(props) {
const appState = inject('appState');
const rowClasses = computed(() => [
'planner-grid-row',
'planner-grid-row-item',
'agent-row-root',
appState.highlightedRowId.value === props.item.data.id ? 'agent-row-highlighted' : '',
appState.crosshairActive.value ? 'planner-grid-crosshair-enabled' : ''
]);
return { ...appState, rowClasses };
},
template: `
<div :class="rowClasses">
<agent-cell :agent="item.data"></agent-cell>
<div class="planner-grid-cells-area">
<day-cell v-for="(date, i) in dates" :key="'c' + item.data.id + i" :agent="item.data" :date="date"></day-cell>
</div>
</div>
`
};

Some files were not shown because too many files have changed in this diff Show More