Added new modules and updated existing logic
This commit is contained in:
23
dev/ui-ux/AG gemini pro/index.html
Normal file
23
dev/ui-ux/AG gemini pro/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Hotline Planner</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
|
||||
<link href="src/style.css" rel="stylesheet" type="text/css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="q-app">
|
||||
<!-- The App component will be mounted here and manage the layout -->
|
||||
</div>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
|
||||
|
||||
<!-- Entry Point -->
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
321
dev/ui-ux/AG gemini pro/src/App.js
Normal file
321
dev/ui-ux/AG gemini pro/src/App.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import PlannerHeader from './components/planner-header/planner-header.js';
|
||||
import PlannerSettings from './components/planner-settings/planner-settings.js';
|
||||
import PlannerFilter from './components/planner-filter/planner-filter.js';
|
||||
import PlannerSidebar from './components/planner-sidebar/planner-sidebar.js';
|
||||
import PlannerGrid from './components/planner-grid/planner-grid.js';
|
||||
|
||||
import {
|
||||
state,
|
||||
loadDataFromDatabase,
|
||||
simulateWssLock,
|
||||
simulateWssPresence,
|
||||
saveAssignment as dbSaveAssignment,
|
||||
getAssignment,
|
||||
formatDateForId,
|
||||
HUBS,
|
||||
DEPARTMENTS,
|
||||
ROLES,
|
||||
SKILLS
|
||||
} from './services/socket-service.js';
|
||||
|
||||
const { ref, computed, onMounted, watch } = Vue;
|
||||
const { useQuasar } = Quasar;
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
PlannerHeader,
|
||||
PlannerSettings,
|
||||
PlannerFilter,
|
||||
PlannerSidebar,
|
||||
PlannerGrid
|
||||
},
|
||||
setup() {
|
||||
const $q = useQuasar();
|
||||
|
||||
// --- UI State ---
|
||||
const leftDrawer = ref(false);
|
||||
const filterDrawer = ref(false);
|
||||
const rightDrawer = ref(false);
|
||||
const editMode = ref('assignment');
|
||||
|
||||
const isCompact = ref(false);
|
||||
const weekendsAreWorkingDays = ref(false);
|
||||
const viewScope = ref(8);
|
||||
const pickerStartDay = ref(1);
|
||||
const search = ref("");
|
||||
const activeDept = ref("All");
|
||||
const activeHub = ref("All");
|
||||
const filterRoles = ref([]);
|
||||
const filterSkills = ref([]);
|
||||
const filterByAvailability = ref(false);
|
||||
const filterDate = ref(new Date().toISOString().split('T')[0]);
|
||||
const filterShiftTypes = ref([]);
|
||||
|
||||
const showEodTargets = ref(false);
|
||||
const showAvailability = ref(false);
|
||||
const crosshairActive = ref(true);
|
||||
|
||||
const selectedAgent = ref(null);
|
||||
const selectedDate = ref(null);
|
||||
const pendingShift = ref(null);
|
||||
|
||||
const highlightedRowId = ref(null);
|
||||
const highlightedDateStr = ref(null);
|
||||
|
||||
// --- Helpers ---
|
||||
const getStartOfWeek = (date) => {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = (day < 1 ? 7 : 0) + day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const startDate = ref(getStartOfWeek(new Date()));
|
||||
|
||||
const dates = computed(() => {
|
||||
const res = [];
|
||||
const now = new Date(startDate.value);
|
||||
for (let i = 0; i < viewScope.value * 7; i++) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + i);
|
||||
res.push(d);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
const isWeekend = (date) => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
// --- Filtering Logic ---
|
||||
const filteredAgents = computed(() => {
|
||||
return state.agents.filter(a => {
|
||||
const term = (search.value || "").toLowerCase();
|
||||
const matchSearch = term === "" || a.name.toLowerCase().includes(term);
|
||||
const matchDept = activeDept.value === 'All' || a.dept === activeDept.value;
|
||||
const matchHub = activeHub.value === 'All' || a.hub === activeHub.value;
|
||||
const matchRoles = filterRoles.value.length === 0 || filterRoles.value.includes(a.role);
|
||||
const matchSkills = filterSkills.value.length === 0 || a.skills.some(s => filterSkills.value.includes(s));
|
||||
|
||||
let matchAvailability = true;
|
||||
if (filterByAvailability.value && filterDate.value) {
|
||||
const d = new Date(filterDate.value);
|
||||
const assignment = getAssignment(a.id, d);
|
||||
if (!weekendsAreWorkingDays.value && isWeekend(d)) matchAvailability = false;
|
||||
else if (!assignment) matchAvailability = false;
|
||||
else if (filterShiftTypes.value.length > 0 && !filterShiftTypes.value.includes(assignment.id)) matchAvailability = false;
|
||||
}
|
||||
return matchSearch && matchDept && matchHub && matchRoles && matchSkills && matchAvailability;
|
||||
});
|
||||
});
|
||||
|
||||
const flattenedList = computed(() => {
|
||||
const result = [];
|
||||
const list = [...filteredAgents.value];
|
||||
|
||||
// Default grouping by hub > dept for now to match index.html logic
|
||||
list.sort((a, b) => {
|
||||
if (a.hub < b.hub) return -1;
|
||||
if (a.hub > b.hub) return 1;
|
||||
if (a.dept < b.dept) return -1;
|
||||
if (a.dept > b.dept) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
let currentHub = null;
|
||||
let currentDept = null;
|
||||
|
||||
list.forEach(agent => {
|
||||
if (agent.hub !== currentHub) {
|
||||
currentHub = agent.hub;
|
||||
currentDept = null;
|
||||
result.push({
|
||||
type: 'header-l1',
|
||||
label: HUBS.find(h => h.id === agent.hub)?.name || agent.hub,
|
||||
id: `hub-${agent.hub}`
|
||||
});
|
||||
}
|
||||
if (agent.dept !== currentDept) {
|
||||
currentDept = agent.dept;
|
||||
result.push({
|
||||
type: 'header-l2',
|
||||
label: agent.dept.toUpperCase() + ' DIVISION',
|
||||
id: `dept-${agent.hub}-${agent.dept}`
|
||||
});
|
||||
}
|
||||
result.push({ type: 'agent', data: agent, id: agent.id });
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const currentAssignmentLabel = computed(() => {
|
||||
if (pendingShift.value) return pendingShift.value.label;
|
||||
if (!selectedAgent.value || !selectedDate.value) return null;
|
||||
const a = getAssignment(selectedAgent.value.id, selectedDate.value);
|
||||
return a ? a.label : null;
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
const clearFilters = () => {
|
||||
search.value = "";
|
||||
activeDept.value = "All";
|
||||
activeHub.value = "All";
|
||||
filterRoles.value = [];
|
||||
filterSkills.value = [];
|
||||
filterByAvailability.value = false;
|
||||
filterShiftTypes.value = [];
|
||||
};
|
||||
|
||||
const applySavedFilter = (key) => {
|
||||
clearFilters();
|
||||
if (key === 'high_potential') filterSkills.value = ["VIP Concierge", "Technical Training"];
|
||||
else if (key === 'remote') filterRoles.value = ["Specialist"];
|
||||
};
|
||||
|
||||
const toggleRowHighlight = (id) => {
|
||||
highlightedRowId.value = highlightedRowId.value === id ? null : id;
|
||||
};
|
||||
|
||||
const toggleColHighlight = (date) => {
|
||||
const str = formatDateForId(date);
|
||||
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
|
||||
};
|
||||
|
||||
const clearHighlights = () => {
|
||||
highlightedRowId.value = null;
|
||||
highlightedDateStr.value = null;
|
||||
};
|
||||
|
||||
const openAssignment = (agent, date) => {
|
||||
editMode.value = 'assignment';
|
||||
selectedAgent.value = agent;
|
||||
selectedDate.value = date;
|
||||
pendingShift.value = null;
|
||||
rightDrawer.value = true;
|
||||
};
|
||||
|
||||
const openProfile = (agent) => {
|
||||
editMode.value = 'profile';
|
||||
selectedAgent.value = agent;
|
||||
selectedDate.value = null;
|
||||
rightDrawer.value = true;
|
||||
};
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (!selectedAgent.value || !selectedDate.value) return;
|
||||
dbSaveAssignment(selectedAgent.value.id, selectedDate.value, pendingShift.value ? pendingShift.value.id : null);
|
||||
rightDrawer.value = false;
|
||||
};
|
||||
|
||||
const resetToToday = () => {
|
||||
startDate.value = getStartOfWeek(new Date());
|
||||
leftDrawer.value = false;
|
||||
};
|
||||
|
||||
const applyDateSelection = (date) => {
|
||||
startDate.value = getStartOfWeek(date);
|
||||
};
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(() => {
|
||||
loadDataFromDatabase(startDate.value, $q.notify);
|
||||
simulateWssPresence($q.notify);
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
leftDrawer, filterDrawer, rightDrawer, editMode,
|
||||
isCompact, weekendsAreWorkingDays, viewScope, pickerStartDay,
|
||||
search, activeDept, activeHub, filterRoles, filterSkills, filterByAvailability, filterDate, filterShiftTypes,
|
||||
showEodTargets, showAvailability, crosshairActive,
|
||||
selectedAgent, selectedDate, pendingShift, currentAssignmentLabel,
|
||||
highlightedRowId, highlightedDateStr,
|
||||
dates, flattenedList,
|
||||
clearFilters, applySavedFilter,
|
||||
toggleRowHighlight, toggleColHighlight, clearHighlights,
|
||||
openAssignment, openProfile, saveAssignment,
|
||||
resetToToday, applyDateSelection,
|
||||
simulateWssLock: () => simulateWssLock($q.notify)
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<planner-header
|
||||
:has-highlights="!!(highlightedRowId || highlightedDateStr)"
|
||||
@toggle-left="leftDrawer = !leftDrawer"
|
||||
@clear-highlights="clearHighlights"
|
||||
/>
|
||||
|
||||
<planner-settings
|
||||
v-model="leftDrawer"
|
||||
v-model:view-scope="viewScope"
|
||||
v-model:picker-start-day="pickerStartDay"
|
||||
v-model:crosshair-active="crosshairActive"
|
||||
v-model:show-eod-targets="showEodTargets"
|
||||
v-model:show-availability="showAvailability"
|
||||
v-model:is-compact="isCompact"
|
||||
v-model:weekends-are-working-days="weekendsAreWorkingDays"
|
||||
@simulate-wss-lock="simulateWssLock"
|
||||
@reset-to-today="resetToToday"
|
||||
/>
|
||||
|
||||
<planner-filter
|
||||
v-model="filterDrawer"
|
||||
v-model:active-dept="activeDept"
|
||||
v-model:active-hub="activeHub"
|
||||
v-model:filter-roles="filterRoles"
|
||||
v-model:filter-skills="filterSkills"
|
||||
v-model:filter-by-availability="filterByAvailability"
|
||||
v-model:filter-date="filterDate"
|
||||
v-model:filter-shift-types="filterShiftTypes"
|
||||
:picker-start-day="pickerStartDay"
|
||||
@clear-filters="clearFilters"
|
||||
@apply-saved-filter="applySavedFilter"
|
||||
/>
|
||||
|
||||
<planner-sidebar
|
||||
v-model="rightDrawer"
|
||||
:edit-mode="editMode"
|
||||
:selected-agent="selectedAgent"
|
||||
:selected-date="selectedDate"
|
||||
:current-assignment-label="currentAssignmentLabel"
|
||||
:pending-shift="pendingShift"
|
||||
@set-pending-shift="pendingShift = $event"
|
||||
@save-assignment="saveAssignment"
|
||||
/>
|
||||
|
||||
<q-page-container>
|
||||
<q-page class="q-pa-none">
|
||||
<planner-grid
|
||||
:loading="state.loading"
|
||||
:show-eod-targets="showEodTargets"
|
||||
:show-availability="showAvailability"
|
||||
:view-scope="viewScope"
|
||||
:dates="dates"
|
||||
:flattened-list="flattenedList"
|
||||
:is-compact="isCompact"
|
||||
:weekends-are-working-days="weekendsAreWorkingDays"
|
||||
:crosshair-active="crosshairActive"
|
||||
:highlighted-row-id="highlightedRowId"
|
||||
:highlighted-date-str="highlightedDateStr"
|
||||
v-model:search="search"
|
||||
:is-filter-active="!!(search || activeDept !== 'All' || activeHub !== 'All' || filterRoles.length || filterSkills.length || filterByAvailability)"
|
||||
:active-filter-count="0"
|
||||
:picker-start-day="pickerStartDay"
|
||||
@toggle-filter="filterDrawer = !filterDrawer"
|
||||
@toggle-row-highlight="toggleRowHighlight"
|
||||
@toggle-col-highlight="toggleColHighlight"
|
||||
@open-assignment="openAssignment"
|
||||
@open-profile="openProfile"
|
||||
@apply-date-selection="applyDateSelection"
|
||||
/>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
.cell {
|
||||
width: var(--cell-width);
|
||||
min-width: var(--cell-width);
|
||||
height: 58px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.cell:hover:not(.cursor-not-allowed) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.cell-compact {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.shift-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.locked-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(241, 245, 249, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.shift-tooltip {
|
||||
padding: 10px 12px;
|
||||
max-width: 220px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
getAssignment,
|
||||
isCellLocked,
|
||||
hasComment,
|
||||
hasNote,
|
||||
getCommentText,
|
||||
getNoteText,
|
||||
formatDateForId
|
||||
} from '../../services/socket-service.js';
|
||||
|
||||
const { computed } = Vue;
|
||||
|
||||
export default {
|
||||
name: 'PlannerCell',
|
||||
props: {
|
||||
agent: Object,
|
||||
date: Date,
|
||||
weekendsAreWorkingDays: Boolean,
|
||||
isCompact: Boolean,
|
||||
isRowHighlighted: Boolean,
|
||||
isColHighlighted: Boolean,
|
||||
isHovered: Boolean
|
||||
},
|
||||
emits: ['click', 'mouseenter', 'mouseleave'],
|
||||
setup(props) {
|
||||
const isWeekend = (date) => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
const cellClass = computed(() => {
|
||||
const isWknd = isWeekend(props.date) && !props.weekendsAreWorkingDays;
|
||||
const locked = isCellLocked(props.agent.id, props.date);
|
||||
|
||||
const classes = ['planner-cell', 'cell'];
|
||||
if (props.isCompact) classes.push('cell-compact');
|
||||
if (isWknd) classes.push('bg-weekend', 'cursor-not-allowed');
|
||||
else if (locked) classes.push('cursor-not-allowed');
|
||||
else classes.push('cursor-pointer');
|
||||
|
||||
if (props.isRowHighlighted && props.isColHighlighted)
|
||||
classes.push('bg-reading-mode-intersection');
|
||||
else if (props.isRowHighlighted || props.isColHighlighted)
|
||||
classes.push('bg-reading-mode');
|
||||
|
||||
if (props.isHovered) classes.push('col-hovered');
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const assignment = computed(() => getAssignment(props.agent.id, props.date));
|
||||
const locked = computed(() => isCellLocked(props.agent.id, props.date));
|
||||
const comment = computed(() => hasComment(props.agent.id, props.date));
|
||||
const note = computed(() => hasNote(props.agent.id, props.date));
|
||||
const commentText = computed(() => getCommentText(props.agent.id, props.date));
|
||||
const noteText = computed(() => getNoteText(props.agent.id, props.date));
|
||||
|
||||
return {
|
||||
cellClass,
|
||||
isWeekend,
|
||||
assignment,
|
||||
locked,
|
||||
comment,
|
||||
note,
|
||||
commentText,
|
||||
noteText
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
:class="cellClass"
|
||||
@mouseenter="$emit('mouseenter')"
|
||||
@mouseleave="$emit('mouseleave')"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<template v-if="weekendsAreWorkingDays || !isWeekend(date)">
|
||||
<div v-if="assignment" class="shift-badge shadow-1" :class="assignment.badgeClass">
|
||||
{{ assignment.label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="locked" class="locked-overlay">
|
||||
<q-icon name="lock" color="blue-grey-3" size="14px">
|
||||
<q-tooltip class="bg-grey-9 text-white shadow-4 q-pa-sm">
|
||||
This cell is currently being edited by another user.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
|
||||
<div class="absolute-bottom-right q-pa-xs row no-wrap" style="gap: 2px">
|
||||
<q-icon v-if="comment" name="chat_bubble" size="8px" color="blue-grey-3" class="cursor-help opacity-60">
|
||||
<q-tooltip class="bg-white text-grey-9 border shadow-4 shift-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-indigo-9 q-mb-xs">User Comment</div>
|
||||
<div class="text-caption text-grey-8">{{ commentText }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
|
||||
<q-icon v-if="note" name="info" size="8px" color="orange-4" class="cursor-help opacity-70">
|
||||
<q-tooltip class="bg-white text-grey-9 border shadow-4 shift-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-orange-9 q-mb-xs">Technical Note</div>
|
||||
<div class="text-caption text-grey-8">{{ noteText }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
.planner-filter .filter-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
color: #94a3b8;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-btn-active {
|
||||
background-color: #e0e7ff !important;
|
||||
border: 1px solid #c7d2fe !important;
|
||||
}
|
||||
|
||||
.filter-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #f59e0b;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
box-shadow: 0 0 0 2px #e0e7ff;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { SHIFTS, HUBS, DEPARTMENTS, ROLES, SKILLS } from '../../services/socket-service.js';
|
||||
|
||||
const { ref } = Vue;
|
||||
|
||||
export default {
|
||||
name: 'PlannerFilter',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
activeDept: String,
|
||||
activeHub: String,
|
||||
filterRoles: Array,
|
||||
filterSkills: Array,
|
||||
filterByAvailability: Boolean,
|
||||
filterDate: String,
|
||||
filterShiftTypes: Array,
|
||||
pickerStartDay: Number
|
||||
},
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'update:activeDept',
|
||||
'update:activeHub',
|
||||
'update:filterRoles',
|
||||
'update:filterSkills',
|
||||
'update:filterByAvailability',
|
||||
'update:filterDate',
|
||||
'update:filterShiftTypes',
|
||||
'clear-filters',
|
||||
'apply-saved-filter'
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const proxyFilterDate = ref(props.filterDate);
|
||||
|
||||
const updateFilterDateProxy = () => {
|
||||
proxyFilterDate.value = props.filterDate;
|
||||
};
|
||||
|
||||
const applyFilterDate = () => {
|
||||
emit('update:filterDate', proxyFilterDate.value);
|
||||
};
|
||||
|
||||
return {
|
||||
HUBS,
|
||||
DEPARTMENTS,
|
||||
ROLES,
|
||||
SKILLS,
|
||||
SHIFTS,
|
||||
proxyFilterDate,
|
||||
updateFilterDateProxy,
|
||||
applyFilterDate,
|
||||
hubOptions: [
|
||||
{ label: 'All Hubs', value: 'All' },
|
||||
...HUBS.map(h => ({ label: h.name, value: h.id }))
|
||||
]
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
side="right"
|
||||
bordered
|
||||
:width="300"
|
||||
class="planner-filter bg-grey-1"
|
||||
>
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="row items-center justify-between q-mb-xs">
|
||||
<div class="text-h6 text-weight-bold text-indigo-10">Filters</div>
|
||||
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="$emit('update:modelValue', false)"></q-btn>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mb-sm">Refine the agent list by skills and expertise.</div>
|
||||
|
||||
<q-btn flat rounded color="red-5" label="Clear All Filters" class="full-width q-mb-lg" size="sm" icon="filter_alt_off" @click="$emit('clear-filters')"></q-btn>
|
||||
|
||||
<div class="filter-section-title">Saved Views</div>
|
||||
<q-list dense class="q-gutter-y-xs">
|
||||
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'all')">
|
||||
<q-item-section avatar><q-icon name="people_outline" size="xs"></q-icon></q-item-section>
|
||||
<q-item-section class="text-weight-medium">All Agents</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'high_potential')">
|
||||
<q-item-section avatar><q-icon name="star_outline" size="xs" color="amber-8"></q-icon></q-item-section>
|
||||
<q-item-section class="text-weight-medium">High Expertise</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-ripple rounded class="rounded-borders" @click="$emit('apply-saved-filter', 'remote')">
|
||||
<q-item-section avatar><q-icon name="house_siding" size="xs" color="blue-6"></q-icon></q-item-section>
|
||||
<q-item-section class="text-weight-medium">Remote Support</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<div class="filter-section-title q-mt-lg">Shift Availability</div>
|
||||
<div class="bg-white q-pa-sm rounded-borders border q-mb-md">
|
||||
<q-toggle
|
||||
:model-value="filterByAvailability"
|
||||
@update:model-value="$emit('update:filterByAvailability', $event)"
|
||||
label="Filter by Active Shift" dense class="q-mb-sm text-weight-medium text-caption" color="green-6"
|
||||
></q-toggle>
|
||||
|
||||
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-xs animated fadeIn">
|
||||
<q-input
|
||||
:model-value="filterDate"
|
||||
readonly
|
||||
filled dense label="Target Date" class="text-caption"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="event" class="cursor-pointer">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale" @before-show="updateFilterDateProxy">
|
||||
<div class="column bg-white">
|
||||
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal color="indigo-8" :first-day-of-week="pickerStartDay">
|
||||
<div class="row items-center justify-end q-pa-sm border-t q-gutter-sm">
|
||||
<q-btn v-close-popup label="Cancel" color="grey-7" flat dense></q-btn>
|
||||
<q-btn v-close-popup label="OK" color="indigo-8" flat dense @click="applyFilterDate"></q-btn>
|
||||
</div>
|
||||
</q-date>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Required Shift Type:</div>
|
||||
<div class="row q-gutter-xs">
|
||||
<q-checkbox
|
||||
v-for="s in SHIFTS" :key="s.id"
|
||||
:model-value="filterShiftTypes"
|
||||
@update:model-value="$emit('update:filterShiftTypes', $event)"
|
||||
:val="s.id" :label="s.label" dense size="xs" :color="s.color.split('-')[0]" class="col-12 text-caption"
|
||||
></q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section-title">Hub Location</div>
|
||||
<q-select
|
||||
:model-value="activeHub"
|
||||
@update:model-value="$emit('update:activeHub', $event)"
|
||||
:options="hubOptions" emit-value map-options outlined dense rounded bg-color="white"
|
||||
></q-select>
|
||||
|
||||
<div class="filter-section-title">Primary Division</div>
|
||||
<q-select
|
||||
:model-value="activeDept"
|
||||
@update:model-value="$emit('update:activeDept', $event)"
|
||||
:options="['All', ...DEPARTMENTS]" outlined dense rounded bg-color="white"
|
||||
></q-select>
|
||||
|
||||
<div class="filter-section-title">Roles</div>
|
||||
<q-select
|
||||
:model-value="filterRoles"
|
||||
@update:model-value="$emit('update:filterRoles', $event)"
|
||||
:options="ROLES" multiple outlined dense rounded use-chips bg-color="white" placeholder="Filter by role..."
|
||||
></q-select>
|
||||
|
||||
<div class="filter-section-title">Skills & Expertise</div>
|
||||
<q-select
|
||||
:model-value="filterSkills"
|
||||
@update:model-value="$emit('update:filterSkills', $event)"
|
||||
:options="SKILLS" multiple outlined dense rounded use-chips bg-color="white" placeholder="Select expertise..."
|
||||
></q-select>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
:root {
|
||||
--left-col-width: 240px;
|
||||
--cell-width: 100px;
|
||||
--h-eod: 42px;
|
||||
--h-status: 34px;
|
||||
--h-dates: 52px;
|
||||
--border-color: #e2e8f0;
|
||||
--highlight-bg: rgba(99, 102, 241, 0.12);
|
||||
--highlight-border: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
:root {
|
||||
--left-col-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-viewport {
|
||||
height: calc(100vh - 64px) !important;
|
||||
overflow: auto !important;
|
||||
background: white;
|
||||
position: relative;
|
||||
display: block;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.planner-content {
|
||||
display: inline-block;
|
||||
min-width: fit-content;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.planner-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.left-col {
|
||||
width: var(--left-col-width);
|
||||
min-width: var(--left-col-width);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sticky Header Classes */
|
||||
.row-eod {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
height: var(--h-eod);
|
||||
background: #fffafb;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
position: sticky;
|
||||
top: var(--h-eod);
|
||||
z-index: 39;
|
||||
height: var(--h-status);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.row-dates {
|
||||
position: sticky;
|
||||
top: calc(var(--h-eod) + var(--h-status));
|
||||
z-index: 38;
|
||||
height: var(--h-dates);
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
.row-eod .left-col {
|
||||
z-index: 60;
|
||||
background: #fffafb;
|
||||
color: #db2777;
|
||||
border-bottom: 1px solid #fce7f3;
|
||||
}
|
||||
|
||||
.row-status .left-col {
|
||||
z-index: 59;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.row-dates .left-col {
|
||||
z-index: 58;
|
||||
background: #fdfdfd;
|
||||
padding-right: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cells-area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Group Headers */
|
||||
.group-header-l1 {
|
||||
background: #eef2ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
color: #3730a3;
|
||||
letter-spacing: 0.05em;
|
||||
height: 36px;
|
||||
border-top: 1px solid #c7d2fe;
|
||||
border-bottom: 1px solid #e0e7ff;
|
||||
}
|
||||
|
||||
.group-header-l2 {
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.025em;
|
||||
height: 32px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.group-header-l1 .left-col {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.group-header-l2 .left-col {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* Highlight Classes */
|
||||
.bg-reading-mode {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
|
||||
.bg-reading-mode-intersection {
|
||||
background-color: #fff176 !important;
|
||||
}
|
||||
|
||||
.reading-active-header {
|
||||
background-color: #fef08a !important;
|
||||
border-bottom: 2px solid #eab308;
|
||||
}
|
||||
|
||||
.row-highlighted .left-col {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
|
||||
.col-hovered {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
box-shadow: inset 1px 0 0 var(--highlight-border), inset -1px 0 0 var(--highlight-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.planner-row-item.crosshair-enabled:hover {
|
||||
background-color: var(--highlight-bg);
|
||||
box-shadow: inset 0 1px 0 var(--highlight-border), inset 0 -1px 0 var(--highlight-border);
|
||||
}
|
||||
|
||||
.planner-row-item.crosshair-enabled:hover .left-col {
|
||||
background-color: #e0e7ff !important;
|
||||
border-top: 1px solid var(--highlight-border);
|
||||
border-bottom: 1px solid var(--highlight-border);
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.grid-search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bg-weekend {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.text-sat {
|
||||
color: #166534 !important;
|
||||
}
|
||||
|
||||
.text-sun {
|
||||
color: #991b1b !important;
|
||||
}
|
||||
|
||||
.agent-row-hover-trigger .agent-highlight-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.agent-row-hover-trigger:hover .agent-highlight-btn,
|
||||
.agent-highlight-btn.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.date-header-hover-trigger .header-highlight-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.date-header-hover-trigger:hover .header-highlight-btn,
|
||||
.header-highlight-btn.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import PlannerCell from '../planner-cell/planner-cell.js';
|
||||
import { getHoliday, getSpecialDay, formatDateForId } from '../../services/socket-service.js';
|
||||
|
||||
const { ref, computed, nextTick } = Vue;
|
||||
|
||||
export default {
|
||||
name: 'PlannerGrid',
|
||||
components: {
|
||||
PlannerCell
|
||||
},
|
||||
props: {
|
||||
loading: Boolean,
|
||||
showEodTargets: Boolean,
|
||||
showAvailability: Boolean,
|
||||
viewScope: Number,
|
||||
dates: Array,
|
||||
flattenedList: Array,
|
||||
isCompact: Boolean,
|
||||
weekendsAreWorkingDays: Boolean,
|
||||
crosshairActive: Boolean,
|
||||
highlightedRowId: [Number, String],
|
||||
highlightedDateStr: String,
|
||||
search: String,
|
||||
isFilterActive: Boolean,
|
||||
activeFilterCount: Number,
|
||||
pickerStartDay: Number
|
||||
},
|
||||
emits: [
|
||||
'update:search',
|
||||
'toggle-filter',
|
||||
'toggle-row-highlight',
|
||||
'toggle-col-highlight',
|
||||
'open-assignment',
|
||||
'open-profile',
|
||||
'reset-to-today',
|
||||
'apply-date-selection'
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const hoveredDateStr = ref(null);
|
||||
const dateMenu = ref(false);
|
||||
const proxyDate = ref(null);
|
||||
const viewport = ref(null);
|
||||
|
||||
const isWeekend = (date) => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
const gridStyles = computed(() => ({
|
||||
'--h-eod': props.showEodTargets ? '42px' : '0px',
|
||||
'--h-status': props.showAvailability ? '34px' : '0px'
|
||||
}));
|
||||
|
||||
const syncProxyDate = () => {
|
||||
proxyDate.value = formatDateForId(new Date());
|
||||
};
|
||||
|
||||
const applyDateSelection = () => {
|
||||
if (!proxyDate.value) return;
|
||||
emit('apply-date-selection', new Date(proxyDate.value));
|
||||
dateMenu.value = false;
|
||||
nextTick(() => {
|
||||
if (viewport.value) viewport.value.scrollLeft = 0;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hoveredDateStr,
|
||||
dateMenu,
|
||||
proxyDate,
|
||||
viewport,
|
||||
isWeekend,
|
||||
gridStyles,
|
||||
getHoliday,
|
||||
getSpecialDay,
|
||||
formatDateForId,
|
||||
syncProxyDate,
|
||||
applyDateSelection
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div id="viewport-target" class="grid-viewport" ref="viewport" :style="gridStyles">
|
||||
<div class="planner-content">
|
||||
<!-- EOD Targets Row -->
|
||||
<div v-if="showEodTargets" class="planner-row row-eod">
|
||||
<div class="left-col text-overline text-weight-bold">EOD Targets</div>
|
||||
<div class="cells-area">
|
||||
<div v-for="(d, i) in dates" :key="'e'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-eod)">
|
||||
<div class="text-weight-bold text-[10px] text-pink-7">94%</div>
|
||||
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded style="width: 60%"></q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Availability Row -->
|
||||
<div v-if="showAvailability" class="planner-row row-status">
|
||||
<div class="left-col text-overline text-grey-5">Availability</div>
|
||||
<div class="cells-area">
|
||||
<div v-for="(d, i) in dates" :key="'s'+i" class="cell" :class="{'col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}" style="height: var(--h-status)">
|
||||
<q-icon name="circle" :color="i % 10 === 0 ? 'red-3' : (i % 5 === 0 ? 'orange-3' : 'green-3')" size="8px"></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates Header Row -->
|
||||
<div class="planner-row row-dates">
|
||||
<div class="left-col">
|
||||
<div class="grid-search-container">
|
||||
<q-input
|
||||
:model-value="search"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
debounce="300" dense outlined rounded
|
||||
:placeholder="$q.screen.lt.sm ? 'Search' : 'Quick search...'"
|
||||
class="full-width" bg-color="white" clearable
|
||||
>
|
||||
<template v-slot:prepend><q-icon name="search" size="xs" color="grey-5"></q-icon></template>
|
||||
</q-input>
|
||||
<q-btn flat round dense icon="filter_alt" :color="isFilterActive ? 'indigo-10' : 'grey-6'" :class="isFilterActive ? 'filter-btn-active' : ''" size="sm" @click="$emit('toggle-filter')">
|
||||
<div v-if="isFilterActive" class="filter-status-dot"></div>
|
||||
<q-tooltip>{{ isFilterActive ? activeFilterCount + ' filters active' : 'Advanced Filters & Expertise' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense icon="calendar_today" color="indigo-7" size="sm">
|
||||
<q-menu v-model="dateMenu" @show="syncProxyDate">
|
||||
<q-card class="dense-date-card shadow-12 border">
|
||||
<q-card-section class="q-pa-none"><q-date v-model="proxyDate" minimal flat color="indigo-8" :first-day-of-week="pickerStartDay"></q-date></q-card-section>
|
||||
<q-card-actions align="right" class="q-pt-none q-pb-sm q-px-md">
|
||||
<q-btn flat dense rounded label="Cancel" color="grey-7" @click="dateMenu = false"></q-btn>
|
||||
<q-btn unelevated dense rounded label="OK" color="indigo-8" class="q-px-md" @click="applyDateSelection"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cells-area">
|
||||
<div v-for="(date, i) in dates"
|
||||
:key="'d'+i"
|
||||
class="cell column flex-center relative-position date-header-hover-trigger transition-colors"
|
||||
:class="[
|
||||
isWeekend(date) && !weekendsAreWorkingDays ? 'bg-weekend' : '',
|
||||
highlightedDateStr === formatDateForId(date) ? 'reading-active-header' : '',
|
||||
crosshairActive && hoveredDateStr === formatDateForId(date) ? 'col-hovered' : ''
|
||||
]"
|
||||
style="height: var(--h-dates)"
|
||||
@mouseenter="hoveredDateStr = formatDateForId(date)"
|
||||
@mouseleave="hoveredDateStr = null"
|
||||
>
|
||||
<div class="absolute-top-right q-pa-xs">
|
||||
<q-icon v-if="getHoliday(date)" name="celebration" size="10px" color="red-5" class="cursor-help">
|
||||
<q-tooltip class="bg-red-9 text-white shadow-4 q-pa-sm">Holiday: {{ getHoliday(date) }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="getSpecialDay(date)" name="bookmark" size="10px" color="indigo-4" class="cursor-help">
|
||||
<q-tooltip class="bg-indigo-9 text-white shadow-4 q-pa-sm">Event: {{ getSpecialDay(date) }}</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="text-[10px] uppercase text-weight-bold" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-5']">
|
||||
{{ date.toLocaleDateString('en-US', {weekday: 'short'}) }}
|
||||
</div>
|
||||
<div class="text-subtitle2 text-weight-bold leading-none" :class="[isWeekend(date) && !weekendsAreWorkingDays ? (date.getDay() === 6 ? 'text-sat' : 'text-sun') : 'text-grey-9']">
|
||||
{{ date.getDate() }}. {{ date.toLocaleDateString('en-US', { month: 'short' }) }}
|
||||
</div>
|
||||
|
||||
<q-btn round flat dense
|
||||
:icon="highlightedDateStr === formatDateForId(date) ? 'visibility_off' : 'visibility'"
|
||||
:color="highlightedDateStr === formatDateForId(date) ? 'amber-9' : 'grey-5'"
|
||||
size="sm"
|
||||
class="header-highlight-btn absolute-bottom-right q-ma-xs"
|
||||
:class="{ 'is-active': highlightedDateStr === formatDateForId(date) }"
|
||||
@click.stop="$emit('toggle-col-highlight', date)">
|
||||
<q-tooltip>{{ highlightedDateStr === formatDateForId(date) ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<div v-if="highlightedDateStr === formatDateForId(date)" class="absolute-bottom full-width bg-amber-8" style="height: 3px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton Loading -->
|
||||
<div v-if="loading">
|
||||
<div v-for="n in 15" :key="n" class="planner-row">
|
||||
<div class="left-col items-center">
|
||||
<q-skeleton type="QAvatar" size="32px"></q-skeleton>
|
||||
<div class="col q-ml-sm">
|
||||
<q-skeleton type="text" width="60%"></q-skeleton>
|
||||
<q-skeleton type="text" width="40%"></q-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cells-area">
|
||||
<div v-for="m in (viewScope || 8) * 7" :key="m" class="cell flex flex-center">
|
||||
<q-skeleton type="rect" width="70%" height="20px"></q-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Scroll Content -->
|
||||
<q-virtual-scroll
|
||||
v-if="!loading"
|
||||
scroll-target="#viewport-target"
|
||||
:items="flattenedList"
|
||||
:item-size="isCompact ? 43 : 59"
|
||||
class="virtual-scroll-target"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<div v-if="item.type === 'header-l1'" :key="item.id" class="planner-row group-header-l1">
|
||||
<div class="left-col group-header-l1">{{ item.label }}</div>
|
||||
<div class="cells-area"><div class="cell" style="flex: 1; height: 36px; border-right: none"></div></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === 'header-l2'" :key="item.id" class="planner-row group-header-l2">
|
||||
<div class="left-col group-header-l2">{{ item.label }}</div>
|
||||
<div class="cells-area"><div class="cell" style="flex: 1; height: 32px; border-right: none"></div></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:key="item.id"
|
||||
class="planner-row planner-row-item"
|
||||
:class="{'row-highlighted': highlightedRowId === item.data.id, 'crosshair-enabled': crosshairActive}"
|
||||
>
|
||||
<div class="left-col border-b cursor-pointer agent-row-hover-trigger relative-position"
|
||||
:class="[isCompact ? 'cell-compact' : '']"
|
||||
@click="$emit('open-profile', item.data)">
|
||||
|
||||
<q-avatar :size="isCompact ? '24px' : '32px'" class="shadow-1"><img :src="item.data.avatar"></q-avatar>
|
||||
<div class="q-ml-sm overflow-hidden col">
|
||||
<div class="text-weight-bold truncate" :style="{fontSize: isCompact ? '11px' : '13px'}">{{ item.data.name }}</div>
|
||||
<div v-if="!isCompact" class="text-[10px] text-grey-5 uppercase text-weight-bold truncate agent-role">{{ item.data.role }}</div>
|
||||
</div>
|
||||
|
||||
<q-btn round flat dense
|
||||
:icon="highlightedRowId === item.data.id ? 'visibility_off' : 'visibility'"
|
||||
:color="highlightedRowId === item.data.id ? 'amber-9' : 'grey-4'"
|
||||
size="sm"
|
||||
class="agent-highlight-btn"
|
||||
:class="{ 'is-active': highlightedRowId === item.data.id }"
|
||||
@click.stop="$emit('toggle-row-highlight', item.data.id)">
|
||||
<q-tooltip>{{ highlightedRowId === item.data.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<div class="cells-area">
|
||||
<planner-cell
|
||||
v-for="(date, i) in dates"
|
||||
:key="'c'+item.data.id+i"
|
||||
:agent="item.data"
|
||||
:date="date"
|
||||
:weekends-are-working-days="weekendsAreWorkingDays"
|
||||
:is-compact="isCompact"
|
||||
:is-row-highlighted="highlightedRowId === item.data.id"
|
||||
:is-col-highlighted="highlightedDateStr === formatDateForId(date)"
|
||||
:is-hovered="crosshairActive && hoveredDateStr === formatDateForId(date)"
|
||||
@mouseenter="hoveredDateStr = formatDateForId(date)"
|
||||
@mouseleave="hoveredDateStr = null"
|
||||
@click="$emit('open-assignment', item.data, date)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.planner-header {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.planner-header .q-toolbar {
|
||||
min-height: 64px;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import PlannerUsersOnline from '../planner-users-online/planner-users-online.js';
|
||||
|
||||
export default {
|
||||
name: 'PlannerHeader',
|
||||
components: {
|
||||
PlannerUsersOnline
|
||||
},
|
||||
props: {
|
||||
hasHighlights: Boolean
|
||||
},
|
||||
emits: ['toggle-left', 'clear-highlights'],
|
||||
template: `
|
||||
<q-header class="planner-header bg-white text-grey-9">
|
||||
<q-toolbar class="q-py-sm">
|
||||
<q-btn flat round dense icon="menu" color="grey-7" @click="$emit('toggle-left')"></q-btn>
|
||||
|
||||
<div class="row items-center q-ml-md">
|
||||
|
||||
<q-toolbar-title class="text-weight-bold text-subtitle1 text-indigo-10 ellipsis" style="max-width: 150px;">
|
||||
Hotline Planner (AG gemini flash)
|
||||
</q-toolbar-title>
|
||||
</div>
|
||||
|
||||
<q-space></q-space>
|
||||
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
|
||||
<q-btn v-if="hasHighlights"
|
||||
outline rounded dense
|
||||
color="amber-9"
|
||||
icon="highlight_off"
|
||||
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
|
||||
class="q-mr-xs bg-amber-1"
|
||||
@click="$emit('clear-highlights')">
|
||||
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
|
||||
</q-btn>
|
||||
</transition>
|
||||
|
||||
<planner-users-online />
|
||||
|
||||
<q-separator vertical inset class="gt-xs q-mx-sm"></q-separator>
|
||||
<q-btn flat round dense icon="notifications" color="grey-6" class="gt-xs"></q-btn>
|
||||
<q-avatar size="32px" class="cursor-pointer q-ml-xs">
|
||||
<img src="https://i.pravatar.cc/150?u=me">
|
||||
</q-avatar>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.planner-settings .q-drawer {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.planner-settings .section-title {
|
||||
font-weight: bold;
|
||||
color: #1a237e;
|
||||
/* indigo-10 */
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
export default {
|
||||
name: 'PlannerSettings',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
viewScope: Number,
|
||||
pickerStartDay: Number,
|
||||
crosshairActive: Boolean,
|
||||
showEodTargets: Boolean,
|
||||
showAvailability: Boolean,
|
||||
isCompact: Boolean,
|
||||
weekendsAreWorkingDays: Boolean
|
||||
},
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'update:viewScope',
|
||||
'update:pickerStartDay',
|
||||
'update:crosshairActive',
|
||||
'update:showEodTargets',
|
||||
'update:showAvailability',
|
||||
'update:isCompact',
|
||||
'update:weekendsAreWorkingDays',
|
||||
'simulate-wss-lock',
|
||||
'reset-to-today'
|
||||
],
|
||||
template: `
|
||||
<q-drawer
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
side="left"
|
||||
bordered
|
||||
:width="300"
|
||||
class="planner-settings bg-white"
|
||||
>
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="text-h6 text-weight-bold q-mb-xs text-indigo-10">Workspace</div>
|
||||
<div class="text-caption text-grey-6 q-mb-xl">Manage grid and picker preferences.</div>
|
||||
|
||||
<div class="column q-gutter-y-lg">
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Timeline Range</div>
|
||||
<q-select
|
||||
:model-value="viewScope"
|
||||
@update:model-value="$emit('update:viewScope', $event)"
|
||||
:options="[4, 8, 12]"
|
||||
outlined dense rounded emit-value map-options suffix=" Weeks" bg-color="white"
|
||||
></q-select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Date Picker Week Start</div>
|
||||
<q-select
|
||||
:model-value="pickerStartDay"
|
||||
@update:model-value="$emit('update:pickerStartDay', $event)"
|
||||
:options="[{label: 'Sunday', value: 0}, {label: 'Monday', value: 1}]"
|
||||
outlined dense rounded emit-value map-options bg-color="white"
|
||||
></q-select>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Dev Tools</div>
|
||||
<q-btn
|
||||
outline rounded color="purple-7" label="Simulate WSS Lock" class="full-width" icon="lock"
|
||||
@click="$emit('simulate-wss-lock')"
|
||||
>
|
||||
<q-tooltip>Simulate receiving a "Lock Cell" message from server</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Grid Visibility</div>
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">Reading Crosshair</q-item-label>
|
||||
<q-item-label caption>Dynamic highlight on hover</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="crosshairActive"
|
||||
@update:model-value="$emit('update:crosshairActive', $event)"
|
||||
color="indigo-8"
|
||||
></q-toggle>
|
||||
</q-section>
|
||||
</q-item>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">Show EOD Targets</q-item-label>
|
||||
<q-item-label caption>Top row KPI</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="showEodTargets"
|
||||
@update:model-value="$emit('update:showEodTargets', $event)"
|
||||
color="pink-6"
|
||||
></q-toggle>
|
||||
</q-section>
|
||||
</q-item>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">Show Availability</q-item-label>
|
||||
<q-item-label caption>Traffic light indicators</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="showAvailability"
|
||||
@update:model-value="$emit('update:showAvailability', $event)"
|
||||
color="orange-6"
|
||||
></q-toggle>
|
||||
</q-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">Compact Grid</q-item-label>
|
||||
<q-item-label caption>High density view</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="isCompact"
|
||||
@update:model-value="$emit('update:isCompact', $event)"
|
||||
color="indigo-8"
|
||||
></q-toggle>
|
||||
</q-section>
|
||||
</q-item>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">Working Weekends</q-item-label>
|
||||
<q-item-label caption>Enable Sat/Sun shifts</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="weekendsAreWorkingDays"
|
||||
@update:model-value="$emit('update:weekendsAreWorkingDays', $event)"
|
||||
color="indigo-8"
|
||||
></q-toggle>
|
||||
</q-section>
|
||||
</q-item>
|
||||
|
||||
<q-btn outline rounded color="indigo-8" label="Reset to Today" icon="today" class="full-width q-mt-md" @click="$emit('reset-to-today')"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
.planner-sidebar .q-drawer {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.planner-sidebar .border-b {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.planner-sidebar .border-t {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.planner-sidebar .border {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SHIFTS } from '../../services/socket-service.js';
|
||||
|
||||
export default {
|
||||
name: 'PlannerSidebar',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
editMode: String,
|
||||
selectedAgent: Object,
|
||||
selectedDate: [Date, String],
|
||||
currentAssignmentLabel: String,
|
||||
pendingShift: Object
|
||||
},
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'set-pending-shift',
|
||||
'save-assignment'
|
||||
],
|
||||
setup() {
|
||||
return { SHIFTS };
|
||||
},
|
||||
template: `
|
||||
<q-drawer
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
side="right"
|
||||
bordered
|
||||
:width="380"
|
||||
overlay
|
||||
elevated
|
||||
class="planner-sidebar"
|
||||
>
|
||||
<div class="column full-height bg-white">
|
||||
<q-toolbar class="bg-white border-b q-px-md" style="height: 64px">
|
||||
<q-toolbar-title class="text-subtitle1 text-weight-bold">
|
||||
{{ editMode === 'assignment' ? 'Edit Assignment' : 'Agent Details' }}
|
||||
</q-toolbar-title>
|
||||
<q-btn flat round dense icon="close" color="grey-5" @click="$emit('update:modelValue', false)"></q-btn>
|
||||
</q-toolbar>
|
||||
|
||||
<q-scroll-area class="col q-pa-lg">
|
||||
<div v-if="selectedAgent" class="row items-center q-mb-xl">
|
||||
<q-avatar size="64px" class="shadow-1"><img :src="selectedAgent.avatar"></q-avatar>
|
||||
<div class="q-ml-md">
|
||||
<div class="text-h6 text-weight-bold">{{ selectedAgent.name }}</div>
|
||||
<div class="text-caption text-grey-7">{{ selectedAgent.dept }} • {{ selectedAgent.role }}</div>
|
||||
<div class="text-caption text-indigo-8 text-weight-bold q-mt-xs">
|
||||
<q-icon name="public" size="xs" class="q-mr-xs"></q-icon> {{ selectedAgent.hubName || selectedAgent.hub }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="editMode === 'assignment' && selectedDate">
|
||||
<div class="q-mb-lg q-pa-md bg-blue-grey-1 rounded-borders border">
|
||||
<div class="row items-center justify-between no-wrap">
|
||||
<div>
|
||||
<div class="text-overline text-blue-grey-4">Shift Date</div>
|
||||
<div class="text-subtitle1 text-weight-bold text-blue-grey-9">
|
||||
{{ new Date(selectedDate).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) }}
|
||||
</div>
|
||||
</div>
|
||||
<q-icon name="event" color="blue-grey-2" size="sm"></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-weight-bold q-mb-md">Standard Shifts</div>
|
||||
<div class="row q-col-gutter-sm q-mb-xl">
|
||||
<div v-for="s in SHIFTS" :key="s.id" class="col-6">
|
||||
<q-btn
|
||||
:color="s.color"
|
||||
:unelevated="currentAssignmentLabel === s.label"
|
||||
:outline="currentAssignmentLabel !== s.label"
|
||||
rounded dense no-caps :label="s.label"
|
||||
class="full-width q-py-sm"
|
||||
@click="$emit('set-pending-shift', s)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
outline rounded dense color="red-4" label="Clear Assignment"
|
||||
class="full-width q-mt-sm" icon="delete_outline"
|
||||
@click="$emit('set-pending-shift', null)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-scroll-area>
|
||||
|
||||
<div class="q-pa-lg border-t row q-gutter-md bg-grey-1">
|
||||
<q-btn flat rounded label="Cancel" class="col" @click="$emit('update:modelValue', false)"></q-btn>
|
||||
<q-btn unelevated rounded color="indigo-10" label="Save Assignment" class="col" @click="$emit('save-assignment')"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
.avatar-stack-item {
|
||||
margin-left: -8px;
|
||||
border: 2px solid white;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.avatar-stack-img:hover {
|
||||
transform: translateY(-2px);
|
||||
z-index: 10;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.avatar-stack-count-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
z-index: 5;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-shadow-card {
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { state } from '../../services/socket-service.js';
|
||||
|
||||
const { computed } = Vue;
|
||||
|
||||
export default {
|
||||
name: 'PlannerUsersOnline',
|
||||
template: `
|
||||
<div class="planner-users-online row items-center">
|
||||
<!-- Desktop View -->
|
||||
<div class="row items-center gt-sm q-mr-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-avatar v-for="user in visibleOnlineUsers" :key="user.id" size="32px" class="avatar-stack-item avatar-stack-img">
|
||||
<img :src="user.img">
|
||||
|
||||
<q-tooltip anchor="bottom middle" self="top middle" :offset="[0, 10]" class="bg-white text-grey-9 shadow-10 q-pa-none rounded-borders no-shadow-card" style="border: 1px solid #e2e8f0; max-width: none">
|
||||
<div class="profile-card row no-wrap items-center">
|
||||
<q-avatar size="54px" class="q-mr-md shadow-1"><img :src="user.img"></q-avatar>
|
||||
<div class="column">
|
||||
<div class="text-weight-bold text-body2">{{ user.name }}</div>
|
||||
<div class="text-caption text-grey-6">{{ user.role }}</div>
|
||||
<div class="text-caption text-blue-6 text-weight-medium">Online Now</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-avatar>
|
||||
<div v-if="remainingOnlineCount > 0" class="avatar-stack-item avatar-stack-count-circle cursor-pointer">
|
||||
+{{ remainingOnlineCount }}
|
||||
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
|
||||
<q-list dense style="min-width: 240px">
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Active Collaborators</q-item-label>
|
||||
<q-separator></q-separator>
|
||||
<q-scroll-area style="height: 250px;">
|
||||
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
|
||||
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
|
||||
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-scroll-area>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile/Tablet View -->
|
||||
<div class="lt-md">
|
||||
<q-btn round flat dense color="grey-6" icon="group">
|
||||
<q-badge color="red" floating rounded>{{ onlineUsers.length }}</q-badge>
|
||||
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
|
||||
<q-list dense style="min-width: 240px">
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">Online Now</q-item-label>
|
||||
<q-separator></q-separator>
|
||||
<q-scroll-area style="height: 250px;">
|
||||
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
|
||||
<q-item-section avatar><q-avatar size="28px"><img :src="user.img"></q-avatar></q-item-section>
|
||||
<q-item-section><q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label><q-item-label caption>{{ user.role }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-scroll-area>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
const onlineUsers = computed(() => state.onlineUsers);
|
||||
|
||||
const visibleOnlineUsers = computed(() => {
|
||||
const list = onlineUsers.value;
|
||||
// <= 5 show all, no +n
|
||||
// > 5 show 5 and +n
|
||||
if (list.length <= 5) return list;
|
||||
return list.slice(0, 5);
|
||||
});
|
||||
|
||||
const remainingOnlineCount = computed(() => {
|
||||
const list = onlineUsers.value;
|
||||
if (list.length <= 5) return 0;
|
||||
return list.length - 5;
|
||||
});
|
||||
|
||||
return {
|
||||
onlineUsers,
|
||||
visibleOnlineUsers,
|
||||
remainingOnlineCount
|
||||
};
|
||||
}
|
||||
};
|
||||
15
dev/ui-ux/AG gemini pro/src/main.js
Normal file
15
dev/ui-ux/AG gemini pro/src/main.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import App from './App.js';
|
||||
|
||||
const { createApp } = Vue;
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(Quasar, {
|
||||
config: {
|
||||
brand: {
|
||||
primary: '#334155'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.mount('#q-app');
|
||||
216
dev/ui-ux/AG gemini pro/src/services/socket-service.js
Normal file
216
dev/ui-ux/AG gemini pro/src/services/socket-service.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* ===================================================
|
||||
* Socket Service – Data Layer & WSS Simulation
|
||||
* =================================================== */
|
||||
|
||||
const { reactive } = Vue;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────
|
||||
export const HUBS = [
|
||||
{ id: 'DE', name: 'GERMANY HUB (DE)' },
|
||||
{ id: 'IT', name: 'ITALY HUB (IT)' },
|
||||
{ id: 'FR', name: 'FRANCE HUB (FR)' },
|
||||
{ id: 'GB', name: 'UNITED KINGDOM (GB)' },
|
||||
{ id: 'ES', name: 'SPAIN HUB (ES)' },
|
||||
{ id: 'AE', name: 'UAE HUB (AE)' },
|
||||
{ id: 'PL', name: 'POLAND HUB (PL)' }
|
||||
];
|
||||
|
||||
export const DEPARTMENTS = ['Support', 'Technical', 'Sales', 'VIP', 'Billing'];
|
||||
export const ROLES = ['Senior Lead', 'Specialist', 'Agent'];
|
||||
export const SKILLS = ['English', 'German', 'French', 'Molecular App', 'Hardware', 'Billing Specialist', 'VIP Concierge', 'Technical Training'];
|
||||
|
||||
export const SHIFTS = [
|
||||
{ id: 'm', label: 'MORNING', color: 'green-7', badgeClass: 'bg-green-1 text-green-9 border-green-2' },
|
||||
{ id: 'a', label: 'AFTERNOON', color: 'blue-7', badgeClass: 'bg-blue-1 text-blue-9 border-blue-2' },
|
||||
{ id: 'h', label: 'HOTLINE', color: 'indigo-7', badgeClass: 'bg-indigo-1 text-indigo-9 border-indigo-2' },
|
||||
{ id: 'e', label: 'EOD ONLY', color: 'pink-7', badgeClass: 'bg-pink-1 text-pink-9 border-pink-2' }
|
||||
];
|
||||
|
||||
const MOCK_COMMENTS = ['Late arrival expected.', 'Dental appointment.', 'Swapped shift.', 'Priority focus.', 'Remote session.'];
|
||||
const MOCK_NOTES = ['Headset check.', 'VPN slow.', 'Training session.', 'Backup Billing.', 'Overtime pending.'];
|
||||
|
||||
// ─── Reactive State ──────────────────────────────────
|
||||
export const state = reactive({
|
||||
agents: [],
|
||||
assignments: {},
|
||||
comments: {},
|
||||
notes: {},
|
||||
holidays: {},
|
||||
specialDays: {},
|
||||
lockedCells: new Set(),
|
||||
onlineUsers: reactive([]),
|
||||
loading: false
|
||||
});
|
||||
|
||||
// ─── Mock Data Pool for Presence Simulation ─────────
|
||||
const USER_POOL = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: 'u' + (i + 1),
|
||||
name: 'User ' + (i + 1),
|
||||
role: ROLES[i % ROLES.length] + ' Collaborator',
|
||||
img: `https://i.pravatar.cc/150?u=${i + 100}`
|
||||
}));
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────
|
||||
export function formatDateForId(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const month = '' + (d.getMonth() + 1);
|
||||
const day = '' + d.getDate();
|
||||
const year = d.getFullYear();
|
||||
return [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');
|
||||
}
|
||||
|
||||
function generateMockAgents(count) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const hub = HUBS[Math.floor(Math.random() * HUBS.length)];
|
||||
return {
|
||||
id: i + 1,
|
||||
name: 'Agent ' + (i + 1),
|
||||
dept: DEPARTMENTS[i % DEPARTMENTS.length],
|
||||
role: ROLES[i % ROLES.length],
|
||||
hub: hub.id,
|
||||
hubName: hub.name,
|
||||
skills: [SKILLS[i % SKILLS.length], SKILLS[(i + 2) % SKILLS.length]],
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Agent' + i
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────
|
||||
export function loadDataFromDatabase(startDate, notifyFn) {
|
||||
state.loading = true;
|
||||
setTimeout(() => {
|
||||
const fetched = generateMockAgents(800);
|
||||
state.agents = fetched;
|
||||
const base = new Date(startDate);
|
||||
|
||||
fetched.forEach(agent => {
|
||||
if (!state.assignments[agent.id]) state.assignments[agent.id] = {};
|
||||
if (!state.comments[agent.id]) state.comments[agent.id] = {};
|
||||
if (!state.notes[agent.id]) state.notes[agent.id] = {};
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dStr = formatDateForId(d);
|
||||
if ((agent.id + i) % 7 === 0) state.assignments[agent.id][dStr] = SHIFTS[0].id;
|
||||
else if ((agent.id + i) % 5 === 0) state.assignments[agent.id][dStr] = SHIFTS[1].id;
|
||||
if ((agent.id + i) % 20 === 0) state.comments[agent.id][dStr] = MOCK_COMMENTS[(agent.id + i) % MOCK_COMMENTS.length];
|
||||
if ((agent.id + i) % 25 === 0) state.notes[agent.id][dStr] = MOCK_NOTES[(agent.id + i) % MOCK_NOTES.length];
|
||||
}
|
||||
});
|
||||
|
||||
for (const key in state.holidays) delete state.holidays[key];
|
||||
for (const key in state.specialDays) delete state.specialDays[key];
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
state.holidays[formatDateForId(tomorrow)] = 'Regional Holiday';
|
||||
|
||||
const eventDay = new Date(today);
|
||||
eventDay.setDate(today.getDate() + 5);
|
||||
state.specialDays[formatDateForId(eventDay)] = 'Quarterly Planning';
|
||||
|
||||
state.loading = false;
|
||||
if (notifyFn) notifyFn({ message: 'Data Loaded: 800 Agents', color: 'positive', position: 'top', timeout: 1000 });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function handleWssMessage(type, payload, notifyFn) {
|
||||
if (type === 'LOCK_CELL') {
|
||||
const key = payload.agentId + ':' + payload.date;
|
||||
state.lockedCells.add(key);
|
||||
if (notifyFn) {
|
||||
const agent = state.agents.find(a => a.id === payload.agentId);
|
||||
const name = agent ? agent.name : 'Unknown Agent';
|
||||
notifyFn({ message: 'WSS: Cell Locked for ' + name + ' on ' + payload.date, color: 'negative', position: 'top', icon: 'lock' });
|
||||
}
|
||||
} else if (type === 'USER_ONLINE') {
|
||||
const alreadyOnline = state.onlineUsers.find(u => u.id === payload.user.id);
|
||||
if (!alreadyOnline) {
|
||||
state.onlineUsers.push(payload.user);
|
||||
if (notifyFn) notifyFn({ message: payload.user.name + ' joined', color: 'info', position: 'bottom-right', timeout: 2000 });
|
||||
}
|
||||
} else if (type === 'USER_OFFLINE') {
|
||||
const index = state.onlineUsers.findIndex(u => u.id === payload.userId);
|
||||
if (index > -1) {
|
||||
const user = state.onlineUsers[index];
|
||||
state.onlineUsers.splice(index, 1);
|
||||
if (notifyFn) notifyFn({ message: user.name + ' left', color: 'grey-7', position: 'bottom-right', timeout: 2000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function simulateWssPresence(notifyFn) {
|
||||
const isAdding = Math.random() > 0.4 || state.onlineUsers.length < 3;
|
||||
if (isAdding && state.onlineUsers.length < 15) {
|
||||
// Add random user from pool not already online
|
||||
const offlineUsers = USER_POOL.filter(u => !state.onlineUsers.find(ou => ou.id === u.id));
|
||||
if (offlineUsers.length > 0) {
|
||||
const user = offlineUsers[Math.floor(Math.random() * offlineUsers.length)];
|
||||
handleWssMessage('USER_ONLINE', { user }, notifyFn);
|
||||
}
|
||||
} else if (state.onlineUsers.length > 1) {
|
||||
// Remove random user
|
||||
const user = state.onlineUsers[Math.floor(Math.random() * state.onlineUsers.length)];
|
||||
handleWssMessage('USER_OFFLINE', { userId: user.id }, notifyFn);
|
||||
}
|
||||
|
||||
// Schedule next update
|
||||
const nextInterval = 3000 + Math.random() * 7000;
|
||||
setTimeout(() => simulateWssPresence(notifyFn), nextInterval);
|
||||
}
|
||||
|
||||
export function simulateWssLock(notifyFn) {
|
||||
if (state.agents.length === 0) return;
|
||||
const agent = state.agents[Math.floor(Math.random() * state.agents.length)];
|
||||
let target = new Date();
|
||||
const day = target.getDay();
|
||||
if (day === 0) target.setDate(target.getDate() + 1);
|
||||
else if (day === 6) target.setDate(target.getDate() + 2);
|
||||
handleWssMessage('LOCK_CELL', { agentId: agent.id, date: formatDateForId(target) }, notifyFn);
|
||||
}
|
||||
|
||||
// ─── Getters ─────────────────────────────────────────
|
||||
export function getAssignment(agentId, date) {
|
||||
const dStr = formatDateForId(date);
|
||||
if (state.assignments[agentId] && state.assignments[agentId][dStr] !== undefined) {
|
||||
return SHIFTS.find(s => s.id === state.assignments[agentId][dStr]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCellLocked(agentId, date) {
|
||||
return state.lockedCells.has(agentId + ':' + formatDateForId(date));
|
||||
}
|
||||
|
||||
export function hasComment(agentId, date) {
|
||||
const dStr = formatDateForId(date);
|
||||
return !!(state.comments[agentId] && state.comments[agentId][dStr]);
|
||||
}
|
||||
|
||||
export function getCommentText(agentId, date) {
|
||||
const dStr = formatDateForId(date);
|
||||
return state.comments[agentId] ? state.comments[agentId][dStr] || '' : '';
|
||||
}
|
||||
|
||||
export function hasNote(agentId, date) {
|
||||
const dStr = formatDateForId(date);
|
||||
return !!(state.notes[agentId] && state.notes[agentId][dStr]);
|
||||
}
|
||||
|
||||
export function getNoteText(agentId, date) {
|
||||
const dStr = formatDateForId(date);
|
||||
return state.notes[agentId] ? state.notes[agentId][dStr] || '' : '';
|
||||
}
|
||||
|
||||
export function getHoliday(date) { return state.holidays[formatDateForId(date)] || null; }
|
||||
export function getSpecialDay(date) { return state.specialDays[formatDateForId(date)] || null; }
|
||||
|
||||
// ─── Mutations ───────────────────────────────────────
|
||||
export function saveAssignment(agentId, date, shiftId) {
|
||||
const dStr = formatDateForId(date);
|
||||
if (!state.assignments[agentId]) state.assignments[agentId] = {};
|
||||
state.assignments[agentId][dStr] = shiftId;
|
||||
}
|
||||
39
dev/ui-ux/AG gemini pro/src/style.css
Normal file
39
dev/ui-ux/AG gemini pro/src/style.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@import "./components/planner-header/planner-header.css";
|
||||
@import "./components/planner-users-online/planner-users-online.css";
|
||||
@import "./components/planner-settings/planner-settings.css";
|
||||
@import "./components/planner-filter/planner-filter.css";
|
||||
@import "./components/planner-sidebar/planner-sidebar.css";
|
||||
@import "./components/planner-grid/planner-grid.css";
|
||||
@import "./components/planner-cell/planner-cell.css";
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f8fafc;
|
||||
color: #334155;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.grid-viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.grid-viewport::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.grid-viewport::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 20px;
|
||||
border: 2px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
15
dev/ui-ux/AG gemini pro/start.command
Executable file
15
dev/ui-ux/AG gemini pro/start.command
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
PORT=5187
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Cleanup existing process on the specified port
|
||||
lsof -ti:$PORT | xargs kill -9 2>/dev/null
|
||||
|
||||
echo "Starting Hotline Planner on port $PORT..."
|
||||
python3 -m http.server $PORT &
|
||||
|
||||
sleep 1
|
||||
open "http://localhost:$PORT"
|
||||
|
||||
echo "Server active. Press Ctrl+C to stop."
|
||||
wait
|
||||
Reference in New Issue
Block a user