Added copy pastec

This commit is contained in:
Dieter Neumann
2026-02-24 14:43:32 +01:00
parent ad734273ce
commit ab255bfd36
76 changed files with 2 additions and 2 deletions

View File

@@ -0,0 +1,53 @@
<!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">
<link rel="stylesheet" href="src/components/selection-toolbar/selection-toolbar.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: #fff9c4 !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: white;
color: #1f2937;
}
.app-header-border {
border-bottom: 1px solid #e2e8f0;
}

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,30 @@
.app-shell-root {
--left-col-width: 240px;
--cell-width: 100px;
--primary-soft: #f8fafc;
--border-color: #e2e8f0;
--highlight-bg: rgba(99, 102, 241, 0.12);
--highlight-border: rgba(99, 102, 241, 0.5);
}
@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: #f8fafc;
color: #334155;
}
.app-is-dragging,
.app-is-dragging * {
user-select: none !important;
-webkit-user-select: none !important;
}

View File

@@ -0,0 +1,801 @@
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 selectedCells = ref(new Set()); // agentId:dateStr
const pivotCell = ref(null); // { agentId, dateStr }
const selectionEndCell = ref(null); // { agentId, dateStr }
const clipboard = ref(null); // { type: 'single'|'block', data: Map }
const isDragging = ref(false);
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;
// If we are just opening the assignment, we might want to clear selection or select this cell
// For now, let's keep it simple: clicking a cell selects it and opens the drawer.
const dateStr = formatDateForId(date);
clearSelection();
selectedCells.value.add(`${agent.id}:${dateStr}`);
pivotCell.value = { agentId: agent.id, dateStr };
editMode.value = 'assignment';
selectedAgent.value = agent;
selectedDate.value = date;
pendingShift.value = null;
rightDrawer.value = true;
};
const toggleSelection = (agentId, date, append = false, range = false) => {
const dateStr = formatDateForId(date);
const key = `${agentId}:${dateStr}`;
if (!append && !range) {
selectedCells.value.clear();
}
if (range && pivotCell.value) {
// Range selection logic
const agentIds = flattenedList.value
.filter(item => item.type === 'agent')
.map(item => item.data.id);
const startAgentIdx = agentIds.indexOf(pivotCell.value.agentId);
const endAgentIdx = agentIds.indexOf(agentId);
const [minAgentIdx, maxAgentIdx] = [Math.min(startAgentIdx, endAgentIdx), Math.max(startAgentIdx, endAgentIdx)];
const dateStrs = dates.value.map(d => formatDateForId(d));
const startDateIdx = dateStrs.indexOf(pivotCell.value.dateStr);
const endDateIdx = dateStrs.indexOf(dateStr);
const [minDateIdx, maxDateIdx] = [Math.min(startDateIdx, endDateIdx), Math.max(startDateIdx, endDateIdx)];
for (let i = minAgentIdx; i <= maxAgentIdx; i++) {
const aId = agentIds[i];
for (let j = minDateIdx; j <= maxDateIdx; j++) {
const dStr = dateStrs[j];
selectedCells.value.add(`${aId}:${dStr}`);
}
}
selectionEndCell.value = { agentId, dateStr };
} else {
if (append && selectedCells.value.has(key)) {
selectedCells.value.delete(key);
} else {
selectedCells.value.add(key);
}
pivotCell.value = { agentId, dateStr };
selectionEndCell.value = { agentId, dateStr };
}
// Force reactivity update for the Set
selectedCells.value = new Set(selectedCells.value);
};
const clearSelection = () => {
selectedCells.value.clear();
selectedCells.value = new Set();
pivotCell.value = null;
selectionEndCell.value = null;
};
const copySelectedCells = () => {
if (selectedCells.value.size === 0) return;
const data = new Map();
let minAgentIdx = Infinity, maxAgentIdx = -Infinity;
let minDateIdx = Infinity, maxDateIdx = -Infinity;
const agentIds = flattenedList.value
.filter(item => item.type === 'agent')
.map(item => item.data.id);
const dateStrs = dates.value.map(d => formatDateForId(d));
const shiftCounts = {};
selectedCells.value.forEach(key => {
const [agentIdStr, dateStr] = key.split(':');
const agentId = parseInt(agentIdStr);
const shiftId = assignments[agentId]?.[dateStr] || null;
data.set(key, { shiftId });
if (shiftId) {
const shift = SHIFTS.find(s => s.id === shiftId);
if (shift) {
shiftCounts[shift.label] = (shiftCounts[shift.label] || 0) + 1;
}
}
const aIdx = agentIds.indexOf(agentId);
const dIdx = dateStrs.indexOf(dateStr);
if (aIdx !== -1) {
minAgentIdx = Math.min(minAgentIdx, aIdx);
maxAgentIdx = Math.max(maxAgentIdx, aIdx);
}
if (dIdx !== -1) {
minDateIdx = Math.min(minDateIdx, dIdx);
maxDateIdx = Math.max(maxDateIdx, dIdx);
}
});
const isSingle = selectedCells.value.size === 1;
let feedback = isSingle ? 'Copied 1 cell' : `Copied ${selectedCells.value.size} cells`;
if (Object.keys(shiftCounts).length > 0) {
const topShift = Object.entries(shiftCounts).sort((a, b) => b[1] - a[1])[0][0];
feedback = `Copied: ${topShift}${isSingle ? '' : ' (+)'}`;
}
clipboard.value = {
type: isSingle ? 'single' : 'block',
data,
sourceStart: { agentIdx: minAgentIdx, dateIdx: minDateIdx },
width: maxDateIdx - minDateIdx + 1,
height: maxAgentIdx - minAgentIdx + 1,
agentIds,
dateStrs
};
notify({
message: feedback,
color: 'indigo-8',
icon: 'content_copy',
position: 'bottom',
timeout: 2000
});
};
const pasteToSelectedCells = () => {
if (!clipboard.value || selectedCells.value.size === 0) return;
const agentIds = flattenedList.value
.filter(item => item.type === 'agent')
.map(item => item.data.id);
const dateStrs = dates.value.map(d => formatDateForId(d));
const newSelection = new Set();
if (clipboard.value.type === 'single') {
// Paste single to all selected
const sourceData = Array.from(clipboard.value.data.values())[0];
// Skip blanks check for single paste: if source is blank, we usually don't want to clear
// the target if "Skip Blanks" is active.
if (sourceData.shiftId === null || sourceData.shiftId === undefined) {
notify({ message: 'Nothing to paste (source is blank)', color: 'warning', position: 'bottom' });
return;
}
selectedCells.value.forEach(key => {
const [agentId, dateStr] = key.split(':');
if (isCellLocked(agentId, parseDateId(dateStr))) return;
if (!assignments[agentId]) assignments[agentId] = {};
assignments[agentId][dateStr] = sourceData.shiftId;
newSelection.add(key);
});
} else {
// Block paste
const targetKeys = Array.from(selectedCells.value);
targetKeys.forEach(tKey => {
const [tAgentIdStr, tDateStr] = tKey.split(':');
const tAgentId = parseInt(tAgentIdStr);
const tAgentIdx = agentIds.indexOf(tAgentId);
const tDateIdx = dateStrs.indexOf(tDateStr);
if (tAgentIdx === -1 || tDateIdx === -1) return;
// Perform paste and simultaneously calculate full range for selection
for (let relAgentIdx = 0; relAgentIdx < clipboard.value.height; relAgentIdx++) {
for (let relDateIdx = 0; relDateIdx < clipboard.value.width; relDateIdx++) {
const targetAgentId = agentIds[tAgentIdx + relAgentIdx];
const targetDateStr = dateStrs[tDateIdx + relDateIdx];
if (targetAgentId && targetDateStr) {
// Add to selection regardless of whether we skip the paste
newSelection.add(`${targetAgentId}:${targetDateStr}`);
// Find the source cell for this relative position
const sAgentId = clipboard.value.agentIds[clipboard.value.sourceStart.agentIdx + relAgentIdx];
const sDateStr = clipboard.value.dateStrs[clipboard.value.sourceStart.dateIdx + relDateIdx];
const sKey = `${sAgentId}:${sDateStr}`;
const val = clipboard.value.data.get(sKey);
// SKIP BLANKS logic: If the source cell is blank, do not overwrite the target
if (val && (val.shiftId !== null && val.shiftId !== undefined)) {
if (!isCellLocked(targetAgentId, parseDateId(targetDateStr))) {
if (!assignments[targetAgentId]) assignments[targetAgentId] = {};
assignments[targetAgentId][targetDateStr] = val.shiftId;
}
}
}
}
}
});
}
// Update selection to show what was pasted
if (newSelection.size > 0) {
selectedCells.value = newSelection;
}
notify({
message: 'Pasted content (skipped blanks)',
color: 'positive',
icon: 'content_paste',
position: 'bottom'
});
};
const handleDragStart = (agentId, date) => {
isDragging.value = true;
toggleSelection(agentId, date, false, false);
};
const handleDragOver = (agentId, date) => {
if (!isDragging.value) return;
toggleSelection(agentId, date, false, true);
};
const handleMouseUp = () => {
isDragging.value = false;
};
const handleSelectStart = (e) => {
if (isDragging.value) {
e.preventDefault();
}
};
const onKeydown = (e) => {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isCtrl && e.key.toLowerCase() === 'c') {
if (selectedCells.value.size > 0 && !rightDrawer.value) {
e.preventDefault();
copySelectedCells();
}
} else if (isCtrl && e.key.toLowerCase() === 'v') {
if (clipboard.value && selectedCells.value.size > 0 && !rightDrawer.value) {
e.preventDefault();
pasteToSelectedCells();
}
} else if (e.key === 'Escape') {
clearSelection();
} else if (isShift && e.key.startsWith('Arrow')) {
if (!pivotCell.value) return;
e.preventDefault();
const agentIds = flattenedList.value
.filter(item => item.type === 'agent')
.map(item => item.data.id);
const dateStrs = dates.value.map(d => formatDateForId(d));
// Selection extension should move from selectionEndCell
const endCell = selectionEndCell.value || pivotCell.value;
let targetAgentIdx = agentIds.indexOf(endCell.agentId);
let targetDateIdx = dateStrs.indexOf(endCell.dateStr);
if (e.key === 'ArrowRight') targetDateIdx++;
else if (e.key === 'ArrowLeft') targetDateIdx--;
else if (e.key === 'ArrowDown') targetAgentIdx++;
else if (e.key === 'ArrowUp') targetAgentIdx--;
const targetAgentId = agentIds[targetAgentIdx];
const targetDateStr = dateStrs[targetDateIdx];
if (targetAgentId && targetDateStr) {
toggleSelection(targetAgentId, parseDateId(targetDateStr), false, 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);
window.addEventListener('keydown', onKeydown);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('selectstart', handleSelectStart);
});
onUnmounted(() => {
if (stopOnlineUsersSimulation) stopOnlineUsersSimulation();
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('selectstart', handleSelectStart);
});
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,
selectedCells,
pivotCell,
clipboard,
toggleSelection,
clearSelection,
copySelectedCells,
pasteToSelectedCells,
isDragging,
handleDragStart,
handleDragOver
};
provide('appState', appState);
return appState;
},
template: `
<div class="app-shell-root" :class="{'app-is-dragging': isDragging}">
<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 #e2e8f0;
}
.assignment-editor-cell-id {
font-size: 10px;
color: #6b7280;
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 #f1f5f9;
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 #f1f5f9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.date-cell-weekend {
background-color: #f1f5f9 !important;
}
.date-cell-weekday {
font-size: 10px;
text-transform: uppercase;
}
.date-cell-date {
line-height: 1.1;
}
.date-cell-text-sat {
color: #166534 !important;
}
.date-cell-text-sun {
color: #991b1b !important;
}
.date-cell-text-default {
color: #64748b;
}
.date-cell-text-strong {
color: #111827;
}
.date-cell-reading-active {
background-color: #fef08a !important;
border-bottom: 2px solid #eab308;
}
.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: #d97706;
}
.date-cell-tooltip-holiday {
background: #7f1d1d;
color: #ffffff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
padding: 6px 8px;
}
.date-cell-tooltip-event {
background: #312e81;
color: #ffffff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
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,119 @@
.day-cell-root {
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;
}
.day-cell-hoverable:hover {
background-color: #f8fafc;
}
.day-cell-compact {
height: 42px;
}
.day-cell-weekend {
background-color: #f1f5f9 !important;
}
.day-cell-reading {
background-color: #fff9c4 !important;
}
.day-cell-reading-intersection {
background-color: #fff176 !important;
}
.day-cell-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);
}
.day-cell-root.day-cell-selected {
outline: 2px solid var(--q-primary);
outline-offset: -2px;
background-color: rgba(51, 65, 85, 0.1);
z-index: 10;
}
.day-cell-root.day-cell-selected:hover {
background-color: rgba(51, 65, 85, 0.15);
}
.day-cell-shift-badge-green {
background-color: #ecfdf3;
color: #14532d;
border-color: #bbf7d0;
}
.day-cell-shift-badge-blue {
background-color: #eff6ff;
color: #1e3a8a;
border-color: #bfdbfe;
}
.day-cell-shift-badge-indigo {
background-color: #eef2ff;
color: #312e81;
border-color: #c7d2fe;
}
.day-cell-shift-badge-pink {
background-color: #fdf2f8;
color: #9d174d;
border-color: #fbcfe8;
}
.day-cell-locked-overlay {
position: absolute;
inset: 0;
background: rgba(241, 245, 249, 0.5);
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 #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
background: white;
color: #111827;
}
.day-cell-lock-tooltip {
background: #111827;
color: #ffffff;
padding: 8px 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.day-cell-comment-icon {
opacity: 0.6;
}
.day-cell-note-icon {
opacity: 0.7;
}

View File

@@ -0,0 +1,122 @@
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 isSelected = computed(() => {
const key = `${props.agent.id}:${dateId.value}`;
return appState.selectedCells.value.has(key);
});
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');
}
if (isSelected.value) {
base.push('day-cell-selected');
}
return base;
});
const handleMouseEnter = () => {
appState.hoveredDateStr.value = dateId.value;
if (appState.isDragging.value) {
appState.handleDragOver(props.agent.id, props.date);
}
};
const handleMouseDown = (e) => {
if (locked.value || (isWeekendDay.value && !appState.weekendsAreWorkingDays.value)) return;
if (e.button !== 0) return; // Only left click
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault(); // CRITICAL: Stop text selection
appState.handleDragStart(props.agent.id, props.date);
};
const onLeave = () => { appState.hoveredDateStr.value = null; };
const handleClick = (e) => {
if (locked.value || (isWeekendDay.value && !appState.weekendsAreWorkingDays.value)) return;
const append = e.ctrlKey || e.metaKey;
const range = e.shiftKey;
appState.toggleSelection(props.agent.id, props.date, append, range);
};
const handleDblClick = () => {
if (locked.value || (isWeekendDay.value && !appState.weekendsAreWorkingDays.value)) return;
appState.openAssignment(props.agent, props.date);
};
return {
...appState,
cellClasses,
assignment,
locked,
handleMouseEnter,
onLeave,
handleClick,
handleMouseDown,
handleDblClick
};
},
template: `
<div
:class="cellClasses"
@mouseenter="handleMouseEnter"
@mouseleave="onLeave"
@mousedown="handleMouseDown"
@click.stop="handleClick"
@dblclick.stop="handleDblClick"
@dragstart.prevent
>
<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 #e2e8f0;
}
.detail-drawer-footer {
border-top: 1px solid #e2e8f0;
}

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 #f1f5f9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.eod-row-text {
font-size: 10px;
font-weight: 700;
color: #be185d;
}
.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 #e2e8f0;
}

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: #94a3b8;
margin-top: 24px;
margin-bottom: 12px;
text-transform: uppercase;
}
.filter-drawer-card {
border: 1px solid #e2e8f0;
}

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: #e0e7ff !important;
border: 1px solid #c7d2fe !important;
}
.grid-search-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;
}
.grid-search-dense-date-card {
width: 290px;
border: 1px solid #e2e8f0;
}
.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: #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-row-l1 .group-header-row-left-col {
background: #eef2ff;
color: #3730a3;
}
.group-header-row-l2 {
background: #f8fafc;
font-size: 11px;
font-weight: 800;
color: #64748b;
letter-spacing: 0.025em;
height: 32px;
border-top: 1px solid #e2e8f0;
}
.group-header-row-l2 .group-header-row-left-col {
background: #f8fafc;
color: #64748b;
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,10 @@
.header-brand-icon-box {
background: #eef2f4;
padding: 4px;
border-radius: 4px;
margin-right: 8px;
}
.header-brand-title {
max-width: 200px;
}

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/AG)
</q-toolbar-title>
</div>
`
};

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 #f1f5f9;
}
.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: white;
box-shadow: 0 15px 20px -5px rgba(0, 0, 0, 0.2);
border: 1px solid #e2e8f0;
}
.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: 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);
}
.online-users-stack-img:hover {
transform: translateY(-2px);
z-index: 10;
border-color: #cbd5e1;
}
.online-users-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;
}
.online-users-stack-profile-card {
min-width: 260px;
padding: 16px;
}
.online-users-stack-tooltip {
background: white;
color: #111827;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
padding: 0;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.online-users-stack-menu {
background: white;
box-shadow: 0 15px 20px -5px rgba(0, 0, 0, 0.2);
border: 1px solid #e2e8f0;
}
.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: white;
position: relative;
display: block;
border-top: 1px solid var(--border-color);
--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: #f1f5f9;
}
.planner-grid-viewport::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 20px;
border: 2px solid #f1f5f9;
}
.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 #f1f5f9;
}
.planner-grid-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;
}
.planner-grid-row-eod {
position: sticky;
top: 0;
z-index: 40;
height: var(--h-eod);
background: #fffafb;
}
.planner-grid-row-status {
position: sticky;
top: var(--h-eod);
z-index: 39;
height: var(--h-status);
background: white;
}
.planner-grid-row-dates {
position: sticky;
top: calc(var(--h-eod) + var(--h-status));
z-index: 38;
height: var(--h-dates);
background: #fdfdfd;
}
.planner-grid-row-eod .planner-grid-left-col {
z-index: 60;
background: #fffafb;
color: #db2777;
border-bottom: 1px solid #fce7f3;
}
.planner-grid-row-status .planner-grid-left-col {
z-index: 59;
background: white;
color: #64748b;
}
.planner-grid-row-dates .planner-grid-left-col {
z-index: 58;
background: #fdfdfd;
padding-right: 12px;
border-bottom: 1px solid var(--border-color);
}
.planner-grid-cells-area {
display: flex;
flex-direction: row;
}
.planner-grid-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-grid-row-item.planner-grid-crosshair-enabled:hover {
background-color: var(--highlight-bg);
box-shadow: inset 0 1px 0 var(--highlight-border), inset 0 -1px 0 var(--highlight-border);
}
.planner-grid-row-item.planner-grid-crosshair-enabled:hover .planner-grid-left-col {
background-color: #e0e7ff !important;
border-top: 1px solid var(--highlight-border);
border-bottom: 1px solid var(--highlight-border);
color: #3730a3;
}
@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,25 @@
import PlannerGrid from '../planner-grid/planner-grid.js';
import SelectionToolbar from '../selection-toolbar/selection-toolbar.js';
const { inject } = Vue;
export default {
name: 'PlannerPage',
components: { PlannerGrid, SelectionToolbar },
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>
<selection-toolbar></selection-toolbar>
<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,41 @@
.selection-toolbar-root {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2000;
pointer-events: none;
}
.selection-toolbar-content {
background-color: #e0f2ff;
/* Light blue from screenshot */
padding: 8px 16px;
border-radius: 12px;
display: flex;
align-items: center;
pointer-events: auto;
min-width: 280px;
justify-content: space-between;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.selection-toolbar-count {
color: #1976d2;
/* Primary blue from screenshot */
font-size: 15px;
margin-right: 8px;
}
.selection-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.selection-toolbar-actions .q-btn {
color: #1976d2 !important;
}
.selection-toolbar-actions .q-btn[disabled] {
opacity: 0.4;
}

View File

@@ -0,0 +1,69 @@
const { inject, computed } = Vue;
export default {
name: 'SelectionToolbar',
setup() {
const appState = inject('appState');
const visible = computed(() => appState.selectedCells.value.size > 0);
const count = computed(() => appState.selectedCells.value.size);
const hasClipboard = computed(() => !!appState.clipboard.value);
return {
...appState,
visible,
count,
hasClipboard
};
},
template: `
<transition
appear
enter-active-class="animated bounceInUp"
leave-active-class="animated bounceOutDown"
>
<div v-if="visible" class="selection-toolbar-root">
<div class="selection-toolbar-content shadow-4">
<div class="selection-toolbar-count text-weight-bold">
{{ count }} selected
</div>
<q-separator vertical inset class="q-mx-sm" style="opacity: 0.2" />
<div class="selection-toolbar-actions">
<q-btn
flat
round
dense
color="primary"
icon="content_copy"
@click="copySelectedCells"
>
<q-tooltip>Copy (Ctrl+C)</q-tooltip>
</q-btn>
<q-btn
flat
round
dense
color="primary"
icon="content_paste"
:disabled="!hasClipboard"
@click="pasteToSelectedCells"
>
<q-tooltip>Paste (Ctrl+V)</q-tooltip>
</q-btn>
<q-btn
flat
round
dense
color="primary"
icon="close"
@click="clearSelection"
class="q-ml-sm"
>
<q-tooltip>Clear Selection (Esc)</q-tooltip>
</q-btn>
</div>
</div>
</div>
</transition>
`
};

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=5190
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