Added new modules and updated existing logic
This commit is contained in:
53
dev/copy paste/Codex -> AG/index.html
Normal file
53
dev/copy paste/Codex -> AG/index.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.agent-row-highlighted .agent-row-left-col {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.app-header-root {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.app-header-border {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
801
dev/copy paste/Codex -> AG/src/components/app-shell/app-shell.js
Normal file
801
dev/copy paste/Codex -> AG/src/components/app-shell/app-shell.js
Normal 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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
108
dev/copy paste/Codex -> AG/src/components/date-cell/date-cell.js
Normal file
108
dev/copy paste/Codex -> AG/src/components/date-cell/date-cell.js
Normal 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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
119
dev/copy paste/Codex -> AG/src/components/day-cell/day-cell.css
Normal file
119
dev/copy paste/Codex -> AG/src/components/day-cell/day-cell.css
Normal 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;
|
||||
}
|
||||
122
dev/copy paste/Codex -> AG/src/components/day-cell/day-cell.js
Normal file
122
dev/copy paste/Codex -> AG/src/components/day-cell/day-cell.js
Normal 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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.detail-drawer-toolbar {
|
||||
height: 64px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-drawer-footer {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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%;
|
||||
}
|
||||
25
dev/copy paste/Codex -> AG/src/components/eod-row/eod-row.js
Normal file
25
dev/copy paste/Codex -> AG/src/components/eod-row/eod-row.js
Normal 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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.filter-availability-actions {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.header-brand-icon-box {
|
||||
background: #eef2f4;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.header-brand-title {
|
||||
max-width: 180px;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.loading-skeleton-left {
|
||||
height: 59px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.loading-skeleton-fade {
|
||||
opacity: 0.3;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
11
dev/copy paste/Codex -> AG/src/main.js
Normal file
11
dev/copy paste/Codex -> AG/src/main.js
Normal 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');
|
||||
284
dev/copy paste/Codex -> AG/src/services/socket-service.js
Normal file
284
dev/copy paste/Codex -> AG/src/services/socket-service.js
Normal 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
|
||||
};
|
||||
15
dev/copy paste/Codex -> AG/start.command
Executable file
15
dev/copy paste/Codex -> AG/start.command
Executable 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
|
||||
Reference in New Issue
Block a user