Added new modules and updated existing logic
This commit is contained in:
35
dev/ui-ux/Opus w images/index.html
Normal file
35
dev/ui-ux/Opus w images/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hotline Planner</title>
|
||||
|
||||
<!-- CDN: Fonts & Quasar -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Roboto:wght@100;300;400;500;700;900&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
|
||||
|
||||
<!-- Application CSS (namespaced per component) -->
|
||||
<link rel="stylesheet" href="src/styles/global.css">
|
||||
<link rel="stylesheet" href="src/components/app-header/app-header.css">
|
||||
<link rel="stylesheet" href="src/components/online-users/online-users.css">
|
||||
<link rel="stylesheet" href="src/components/workspace-drawer/workspace-drawer.css">
|
||||
<link rel="stylesheet" href="src/components/filter-drawer/filter-drawer.css">
|
||||
<link rel="stylesheet" href="src/components/assignment-drawer/assignment-drawer.css">
|
||||
<link rel="stylesheet" href="src/components/planner-grid/planner-grid.css">
|
||||
<link rel="stylesheet" href="src/components/grid-cell/grid-cell.css">
|
||||
<link rel="stylesheet" href="src/components/date-header/date-header.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="q-app"></div>
|
||||
|
||||
<!-- CDN: Vue 3 + Quasar UMD (must load before ES modules) -->
|
||||
<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>
|
||||
|
||||
<!-- Application entry point -->
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
114
dev/ui-ux/Opus w images/src/components/app-header/app-header.css
Normal file
114
dev/ui-ux/Opus w images/src/components/app-header/app-header.css
Normal file
@@ -0,0 +1,114 @@
|
||||
/* app-header — modern top toolbar */
|
||||
|
||||
.app-header {
|
||||
background: #ffffff !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.app-header-toolbar {
|
||||
min-height: 56px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-header-menu-btn {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.app-header-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
letter-spacing: -0.02em;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Center nav group */
|
||||
.app-header-nav {
|
||||
gap: 4px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.app-header-nav-btn {
|
||||
color: #64748b !important;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.app-header-nav-btn:hover {
|
||||
color: #2563eb !important;
|
||||
background: #dbeafe !important;
|
||||
}
|
||||
|
||||
.app-header-month-label {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header-jump-today {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.app-header-jump-today:hover {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.app-header-clear-btn {
|
||||
background: #fef3c7 !important;
|
||||
border-color: #f59e0b !important;
|
||||
}
|
||||
|
||||
/* Live label */
|
||||
.app-header-live-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-right: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Save button */
|
||||
.app-header-save-btn {
|
||||
padding: 6px 20px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 13px !important;
|
||||
letter-spacing: 0 !important;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 599px) {
|
||||
.app-header-toolbar {
|
||||
padding: 0 8px;
|
||||
}
|
||||
.app-header-logo {
|
||||
font-size: 15px;
|
||||
}
|
||||
.app-header-nav {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.app-header-month-label {
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.app-header-jump-today {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* app-header.js
|
||||
* ==============
|
||||
* Top toolbar: logo, month navigation, Jump to Today,
|
||||
* online-users, Save Changes button.
|
||||
*/
|
||||
|
||||
import {
|
||||
leftDrawer,
|
||||
highlightedRowId,
|
||||
highlightedDateStr,
|
||||
clearHighlights,
|
||||
currentMonthLabel,
|
||||
navigateMonth,
|
||||
jumpToToday
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import OnlineUsers from '../online-users/online-users.js';
|
||||
|
||||
export default {
|
||||
name: 'AppHeader',
|
||||
components: { OnlineUsers },
|
||||
setup() {
|
||||
return {
|
||||
leftDrawer,
|
||||
highlightedRowId,
|
||||
highlightedDateStr,
|
||||
clearHighlights,
|
||||
currentMonthLabel,
|
||||
navigateMonth,
|
||||
jumpToToday
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-header class="app-header" bordered>
|
||||
<q-toolbar class="app-header-toolbar">
|
||||
|
||||
<!-- Left: Logo & Menu -->
|
||||
<div class="row items-center no-wrap">
|
||||
<q-btn flat round dense icon="menu" class="app-header-menu-btn q-mr-sm" @click="leftDrawer = !leftDrawer"></q-btn>
|
||||
<div class="app-header-logo">Hotline Planner</div>
|
||||
</div>
|
||||
|
||||
<q-space></q-space>
|
||||
|
||||
<!-- Center: Month Navigation -->
|
||||
<div class="app-header-nav row items-center no-wrap">
|
||||
<q-btn flat round dense icon="chevron_left" class="app-header-nav-btn"
|
||||
@click="navigateMonth(-1)">
|
||||
<q-tooltip>Previous Week</q-tooltip>
|
||||
</q-btn>
|
||||
<div class="app-header-month-label">{{ currentMonthLabel }}</div>
|
||||
<q-btn flat round dense icon="chevron_right" class="app-header-nav-btn"
|
||||
@click="navigateMonth(1)">
|
||||
<q-tooltip>Next Week</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<div class="app-header-jump-today" @click="jumpToToday">
|
||||
Jump to Today
|
||||
</div>
|
||||
|
||||
<!-- Clear highlights button -->
|
||||
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
|
||||
<q-btn v-if="highlightedRowId || highlightedDateStr"
|
||||
outline rounded dense size="sm"
|
||||
color="amber-9"
|
||||
icon="highlight_off"
|
||||
label="Clear"
|
||||
class="q-ml-md app-header-clear-btn"
|
||||
@click="clearHighlights">
|
||||
<q-tooltip>Clear all highlights</q-tooltip>
|
||||
</q-btn>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<q-space></q-space>
|
||||
|
||||
<!-- Right: Live users + Save -->
|
||||
<div class="row items-center no-wrap q-gutter-sm">
|
||||
<div class="app-header-live-label gt-sm">Live:</div>
|
||||
<online-users></online-users>
|
||||
|
||||
<q-btn unelevated rounded no-caps
|
||||
color="blue-7"
|
||||
icon="save"
|
||||
label="Save Changes"
|
||||
class="app-header-save-btn gt-xs">
|
||||
<q-tooltip>Save all pending changes</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense icon="save" color="blue-7" class="lt-sm">
|
||||
<q-tooltip>Save Changes</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
/* assignment-drawer — Non-Hotline Activities panel */
|
||||
|
||||
.assignment-drawer {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assignment-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: white;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.assignment-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Agent profile card */
|
||||
.assignment-drawer-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e8ecf1;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.assignment-drawer-avatar {
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assignment-drawer-agent-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.assignment-drawer-agent-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Section title */
|
||||
.assignment-drawer-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Field groups */
|
||||
.assignment-drawer-field-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.assignment-drawer-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.assignment-drawer-info-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
/* Add button */
|
||||
.assignment-drawer-add-btn {
|
||||
margin-top: 8px;
|
||||
padding: 10px 0 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px !important;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Exception cards */
|
||||
.assignment-drawer-exception {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e8ecf1;
|
||||
margin-bottom: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.assignment-drawer-exception:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.assignment-drawer-exception-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assignment-drawer-exception-time {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.assignment-drawer-exception-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.assignment-drawer-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* assignment-drawer.js
|
||||
* =====================
|
||||
* Right overlay drawer: Non-Hotline Activities panel.
|
||||
* Shows agent profile, time range inputs, activity type,
|
||||
* exceptions list, and shift total.
|
||||
*/
|
||||
|
||||
const { ref, computed } = Vue;
|
||||
|
||||
import {
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedAgent,
|
||||
selectedDate,
|
||||
pendingShift,
|
||||
formatDateForId
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
SHIFTS,
|
||||
ACTIVITY_TYPES,
|
||||
getAssignment,
|
||||
getShifts,
|
||||
assignments,
|
||||
saveAssignment as doSave
|
||||
} from '../../services/data-service.js';
|
||||
|
||||
export default {
|
||||
name: 'AssignmentDrawer',
|
||||
setup() {
|
||||
const newTimeStart = ref('14:00');
|
||||
const newTimeEnd = ref('15:00');
|
||||
const newActivityType = ref(null);
|
||||
const newNote = ref('');
|
||||
|
||||
// Mock exceptions for display
|
||||
const exceptions = ref([
|
||||
{ id: 1, timeStart: '11:00', timeEnd: '12:00', label: 'Recurring Team Sync', color: '#7c3aed' },
|
||||
{ id: 2, timeStart: '13:00', timeEnd: '13:30', label: 'Lunch Break (Late)', color: '#f59e0b' },
|
||||
]);
|
||||
|
||||
const timeOptions = [];
|
||||
for (let h = 6; h <= 22; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
timeOptions.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const activityOptions = ACTIVITY_TYPES.map(a => ({
|
||||
label: a.label,
|
||||
value: a.id,
|
||||
color: a.badgeColor
|
||||
}));
|
||||
|
||||
const dateLabel = computed(() => {
|
||||
if (!selectedDate.value) return '';
|
||||
return selectedDate.value.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const shiftTimeLabel = computed(() => {
|
||||
if (!selectedAgent.value || !selectedDate.value) return '';
|
||||
const shifts = getShifts(selectedAgent.value.id, selectedDate.value);
|
||||
if (!shifts || shifts.length === 0) return 'No shift';
|
||||
const first = shifts[0];
|
||||
if (first.type === 'shift') return `${first.timeStart} - ${first.timeEnd}`;
|
||||
return first.label || '';
|
||||
});
|
||||
|
||||
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.activityLabel || a.label) : null;
|
||||
});
|
||||
|
||||
const setPendingShift = (shift) => { pendingShift.value = shift; };
|
||||
|
||||
const saveAssignment = () => {
|
||||
doSave(selectedAgent, selectedDate, pendingShift, rightDrawer);
|
||||
};
|
||||
|
||||
const addException = () => {
|
||||
// Mock: add to local list
|
||||
if (newActivityType.value) {
|
||||
const activity = ACTIVITY_TYPES.find(a => a.id === newActivityType.value);
|
||||
exceptions.value.push({
|
||||
id: Date.now(),
|
||||
timeStart: newTimeStart.value,
|
||||
timeEnd: newTimeEnd.value,
|
||||
label: (activity ? activity.label : 'Activity') + (newNote.value ? ': ' + newNote.value : ''),
|
||||
color: activity ? activity.textColor : '#64748b'
|
||||
});
|
||||
newNote.value = '';
|
||||
newActivityType.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const removeException = (id) => {
|
||||
exceptions.value = exceptions.value.filter(e => e.id !== id);
|
||||
};
|
||||
|
||||
return {
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedAgent,
|
||||
selectedDate,
|
||||
pendingShift,
|
||||
formatDateForId,
|
||||
shifts: SHIFTS,
|
||||
activityOptions,
|
||||
timeOptions,
|
||||
currentAssignmentLabel,
|
||||
dateLabel,
|
||||
shiftTimeLabel,
|
||||
newTimeStart,
|
||||
newTimeEnd,
|
||||
newActivityType,
|
||||
newNote,
|
||||
exceptions,
|
||||
setPendingShift,
|
||||
saveAssignment,
|
||||
addException,
|
||||
removeException
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer v-model="rightDrawer" side="right" bordered :width="380" overlay elevated
|
||||
class="assignment-drawer">
|
||||
<div class="column full-height">
|
||||
<!-- Header -->
|
||||
<div class="assignment-drawer-header">
|
||||
<div class="assignment-drawer-title">
|
||||
{{ editMode === 'assignment' ? 'Non-Hotline Activities' : 'Agent Details' }}
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="rightDrawer = false"></q-btn>
|
||||
</div>
|
||||
|
||||
<q-scroll-area class="col">
|
||||
<div class="q-pa-lg">
|
||||
<!-- Agent Profile Card -->
|
||||
<div v-if="selectedAgent" class="assignment-drawer-profile">
|
||||
<q-avatar size="56px" class="assignment-drawer-avatar">
|
||||
<img :src="selectedAgent.avatar">
|
||||
</q-avatar>
|
||||
<div class="q-ml-md">
|
||||
<div class="assignment-drawer-agent-name">{{ selectedAgent.name }}</div>
|
||||
<div class="assignment-drawer-agent-meta" v-if="selectedDate">
|
||||
{{ dateLabel }} • {{ shiftTimeLabel }}
|
||||
</div>
|
||||
<div class="assignment-drawer-agent-meta" v-else>
|
||||
{{ selectedAgent.dept }} • {{ selectedAgent.role }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="editMode === 'assignment' && selectedDate">
|
||||
<!-- ADD NEW ACTIVITY Section -->
|
||||
<div class="assignment-drawer-section-title">ADD NEW ACTIVITY</div>
|
||||
|
||||
<div class="assignment-drawer-field-group">
|
||||
<div class="assignment-drawer-field-label">Time Range</div>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-select v-model="newTimeStart" :options="timeOptions"
|
||||
outlined dense rounded bg-color="white"
|
||||
class="col" style="min-width: 100px">
|
||||
</q-select>
|
||||
<q-select v-model="newTimeEnd" :options="timeOptions"
|
||||
outlined dense rounded bg-color="white"
|
||||
class="col" style="min-width: 100px">
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="assignment-drawer-field-group">
|
||||
<div class="assignment-drawer-field-label">Activity Type</div>
|
||||
<q-select v-model="newActivityType" :options="activityOptions"
|
||||
outlined dense rounded bg-color="white"
|
||||
emit-value map-options
|
||||
placeholder="Select type...">
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<div class="assignment-drawer-field-group">
|
||||
<div class="assignment-drawer-field-label">Note (Optional)</div>
|
||||
<q-input v-model="newNote" type="textarea" rows="3"
|
||||
outlined dense rounded bg-color="white"
|
||||
placeholder="Details...">
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-btn unelevated rounded no-caps
|
||||
color="blue-7"
|
||||
label="Add Exception"
|
||||
class="full-width assignment-drawer-add-btn"
|
||||
@click="addException">
|
||||
</q-btn>
|
||||
|
||||
<!-- CURRENT EXCEPTIONS -->
|
||||
<div class="assignment-drawer-section-title q-mt-xl">CURRENT EXCEPTIONS</div>
|
||||
|
||||
<div v-if="exceptions.length === 0" class="text-grey-5 text-caption text-center q-pa-md">
|
||||
No exceptions added yet.
|
||||
</div>
|
||||
|
||||
<div v-for="exc in exceptions" :key="exc.id" class="assignment-drawer-exception">
|
||||
<div class="assignment-drawer-exception-dot" :style="{ backgroundColor: exc.color }"></div>
|
||||
<div class="col">
|
||||
<div class="assignment-drawer-exception-time">{{ exc.timeStart }} - {{ exc.timeEnd }}</div>
|
||||
<div class="assignment-drawer-exception-label">{{ exc.label }}</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" size="xs" color="grey-4"
|
||||
@click="removeException(exc.id)">
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Shift buttons (collapsible) -->
|
||||
<q-expansion-item label="Quick Shift Assignment" header-class="text-weight-bold text-grey-7 q-mt-lg"
|
||||
dense dense-toggle>
|
||||
<div class="row q-col-gutter-sm q-pa-sm">
|
||||
<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-xs" style="font-size: 11px"
|
||||
@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-xs" icon="delete_outline" size="sm"
|
||||
@click="setPendingShift(null)"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Profile mode -->
|
||||
<template v-if="editMode === 'profile' && selectedAgent">
|
||||
<div class="q-mt-lg">
|
||||
<div class="assignment-drawer-field-label">Hub Location</div>
|
||||
<div class="assignment-drawer-info-value">
|
||||
<q-icon name="public" size="xs" class="q-mr-xs" color="blue-7"></q-icon>
|
||||
{{ selectedAgent.hubName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-mt-md">
|
||||
<div class="assignment-drawer-field-label">Department</div>
|
||||
<div class="assignment-drawer-info-value">{{ selectedAgent.dept }}</div>
|
||||
</div>
|
||||
<div class="q-mt-md">
|
||||
<div class="assignment-drawer-field-label">Role</div>
|
||||
<div class="assignment-drawer-info-value">{{ selectedAgent.role }}</div>
|
||||
</div>
|
||||
<div class="q-mt-md">
|
||||
<div class="assignment-drawer-field-label">Skills</div>
|
||||
<div class="row q-gutter-xs q-mt-xs">
|
||||
<span v-for="skill in selectedAgent.skills" :key="skill"
|
||||
class="skill-badge skill-badge--blue">{{ skill }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="assignment-drawer-footer">
|
||||
<div class="row items-center justify-between full-width">
|
||||
<div class="text-caption text-grey-6">Shift Total:</div>
|
||||
<div class="text-weight-bold" style="color: var(--text-primary)">7h 30m</div>
|
||||
</div>
|
||||
<div class="row q-gutter-sm q-mt-sm full-width">
|
||||
<q-btn flat rounded no-caps label="Cancel" class="col" color="grey-7" @click="rightDrawer = false"></q-btn>
|
||||
<q-btn unelevated rounded no-caps color="blue-7" label="Save" class="col" @click="saveAssignment"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/* date-header — single column header for a date */
|
||||
|
||||
.date-header-root {
|
||||
width: var(--cell-width);
|
||||
min-width: var(--cell-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: var(--h-dates);
|
||||
border-right: 1px solid #f1f5f9;
|
||||
transition: background 0.15s ease;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* Colored top bar */
|
||||
.date-header-top-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
/* Event indicator */
|
||||
.date-header-event-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
/* Weekday label */
|
||||
.date-header-weekday {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* TODAY label */
|
||||
.date-header-today-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
color: #2563eb;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* Date text */
|
||||
.date-header-date {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.date-header-date-today {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
/* Weekend styling */
|
||||
.date-header-bg-weekend {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
.date-header-text-weekend {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
/* Today column */
|
||||
.date-header-today {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
.date-header-today .date-header-top-bar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* Reading mode highlight */
|
||||
.date-header-reading-active {
|
||||
background-color: #fef08a !important;
|
||||
border-bottom: 2px solid #eab308;
|
||||
}
|
||||
|
||||
/* Crosshair hover */
|
||||
.date-header-col-hovered {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
box-shadow: inset 1px 0 0 var(--highlight-border), inset -1px 0 0 var(--highlight-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Highlight button - hidden by default, shown on hover */
|
||||
.date-header-highlight-btn {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.date-header-root:hover .date-header-highlight-btn,
|
||||
.date-header-highlight-btn.date-header-is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* date-header.js
|
||||
* ===============
|
||||
* A single date-column header cell.
|
||||
* Shows weekday, day number, TODAY highlight, colored top bar.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatDateForId,
|
||||
isWeekend,
|
||||
isToday,
|
||||
weekendsAreWorkingDays,
|
||||
crosshairActive,
|
||||
hoveredDateStr,
|
||||
highlightedDateStr,
|
||||
toggleColHighlight
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import { getHoliday, getSpecialDay } from '../../services/data-service.js';
|
||||
|
||||
export default {
|
||||
name: 'DateHeader',
|
||||
props: {
|
||||
date: { type: Date, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
const dateStr = Vue.computed(() => formatDateForId(props.date));
|
||||
const today = Vue.computed(() => isToday(props.date));
|
||||
|
||||
const rootClass = Vue.computed(() => {
|
||||
const cls = ['date-header-root'];
|
||||
if (isWeekend(props.date) && !weekendsAreWorkingDays.value) cls.push('date-header-bg-weekend');
|
||||
if (highlightedDateStr.value === dateStr.value) cls.push('date-header-reading-active');
|
||||
if (crosshairActive.value && hoveredDateStr.value === dateStr.value) cls.push('date-header-col-hovered');
|
||||
if (today.value) cls.push('date-header-today');
|
||||
return cls.join(' ');
|
||||
});
|
||||
|
||||
const isWkndNonWorking = Vue.computed(() => isWeekend(props.date) && !weekendsAreWorkingDays.value);
|
||||
|
||||
const weekdayShort = Vue.computed(() =>
|
||||
props.date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()
|
||||
);
|
||||
const dayNum = Vue.computed(() => props.date.getDate());
|
||||
const monthShort = Vue.computed(() =>
|
||||
props.date.toLocaleDateString('en-US', { month: 'short' })
|
||||
);
|
||||
|
||||
// Color bar at top based on day pattern
|
||||
const topBarColor = Vue.computed(() => {
|
||||
if (today.value) return '#2563eb';
|
||||
const day = props.date.getDay();
|
||||
if (day === 1) return '#22c55e'; // Mon - green
|
||||
if (day === 2) return '#22c55e'; // Tue - green
|
||||
if (day === 3) return '#f59e0b'; // Wed - amber
|
||||
if (day === 4) return '#ef4444'; // Thu - red
|
||||
if (day === 5) return '#22c55e'; // Fri - green
|
||||
if (day === 6) return '#94a3b8'; // Sat - gray
|
||||
if (day === 0) return '#94a3b8'; // Sun - gray
|
||||
return '#22c55e';
|
||||
});
|
||||
|
||||
const holiday = Vue.computed(() => getHoliday(props.date));
|
||||
const specialDay = Vue.computed(() => getSpecialDay(props.date));
|
||||
const isHighlighted = Vue.computed(() => highlightedDateStr.value === dateStr.value);
|
||||
|
||||
const onEnter = () => { hoveredDateStr.value = dateStr.value; };
|
||||
const onLeave = () => { hoveredDateStr.value = null; };
|
||||
const onToggle = () => { toggleColHighlight(props.date); };
|
||||
|
||||
return {
|
||||
rootClass, today, isWkndNonWorking,
|
||||
weekdayShort, dayNum, monthShort, topBarColor,
|
||||
holiday, specialDay, isHighlighted, dateStr,
|
||||
onEnter, onLeave, onToggle
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div :class="rootClass"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave">
|
||||
|
||||
<!-- Colored top bar -->
|
||||
<div class="date-header-top-bar" :style="{ backgroundColor: topBarColor }"></div>
|
||||
|
||||
<!-- Holiday/event indicator -->
|
||||
<div v-if="holiday || specialDay" class="date-header-event-dot">
|
||||
<q-icon v-if="holiday" name="celebration" size="10px" color="red-5" class="cursor-help">
|
||||
<q-tooltip class="bg-red-9 text-white shadow-4 q-pa-sm">Holiday: {{ holiday }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="specialDay" name="bookmark" size="10px" color="indigo-4" class="cursor-help">
|
||||
<q-tooltip class="bg-indigo-9 text-white shadow-4 q-pa-sm">Event: {{ specialDay }}</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
|
||||
<!-- TODAY label -->
|
||||
<div v-if="today" class="date-header-today-label">TODAY</div>
|
||||
<div v-else class="date-header-weekday" :class="isWkndNonWorking ? 'date-header-text-weekend' : ''">{{ weekdayShort }}</div>
|
||||
|
||||
<div class="date-header-date" :class="[today ? 'date-header-date-today' : '', isWkndNonWorking ? 'date-header-text-weekend' : '']">
|
||||
{{ monthShort }} {{ dayNum }}
|
||||
</div>
|
||||
|
||||
<!-- Highlight toggle on hover -->
|
||||
<q-btn round flat dense
|
||||
:icon="isHighlighted ? 'visibility_off' : 'visibility'"
|
||||
:color="isHighlighted ? 'amber-9' : 'grey-5'"
|
||||
size="sm"
|
||||
class="date-header-highlight-btn"
|
||||
:class="{ 'date-header-is-active': isHighlighted }"
|
||||
@click.stop="onToggle">
|
||||
<q-tooltip>{{ isHighlighted ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/* filter-drawer — right side filter panel */
|
||||
|
||||
.filter-drawer-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* filter-drawer.js
|
||||
* ==================
|
||||
* Right filter drawer: saved views, shift availability filter,
|
||||
* hub / dept / role / skill selects.
|
||||
*/
|
||||
|
||||
import {
|
||||
filterDrawer,
|
||||
pickerStartDay,
|
||||
clearFilters,
|
||||
applySavedFilter,
|
||||
filterByAvailability,
|
||||
filterDate,
|
||||
proxyFilterDate,
|
||||
filterShiftTypes,
|
||||
activeHub,
|
||||
activeDept,
|
||||
filterRoles,
|
||||
filterSkills,
|
||||
updateFilterDateProxy,
|
||||
applyFilterDate
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
SHIFTS,
|
||||
ACTIVITY_TYPES,
|
||||
DEPARTMENTS,
|
||||
ROLES,
|
||||
SKILLS,
|
||||
hubOptions
|
||||
} from '../../services/data-service.js';
|
||||
|
||||
export default {
|
||||
name: 'FilterDrawer',
|
||||
setup() {
|
||||
return {
|
||||
filterDrawer,
|
||||
pickerStartDay,
|
||||
clearFilters,
|
||||
applySavedFilter,
|
||||
filterByAvailability,
|
||||
filterDate,
|
||||
proxyFilterDate,
|
||||
filterShiftTypes,
|
||||
activeHub,
|
||||
activeDept,
|
||||
filterRoles,
|
||||
filterSkills,
|
||||
updateFilterDateProxy,
|
||||
applyFilterDate,
|
||||
shifts: SHIFTS,
|
||||
activityTypes: ACTIVITY_TYPES,
|
||||
depts: DEPARTMENTS,
|
||||
roles: ROLES,
|
||||
allSkills: SKILLS,
|
||||
hubOptions
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer v-model="filterDrawer" side="right" bordered :width="300" class="bg-white">
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="row items-center justify-between q-mb-xs">
|
||||
<div style="font-size: 18px; font-weight: 700; color: var(--text-primary)">Filters</div>
|
||||
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="filterDrawer = false"></q-btn>
|
||||
</div>
|
||||
<div class="text-caption q-mb-sm" style="color: var(--text-muted)">Refine the agent list by skills and expertise.</div>
|
||||
|
||||
<q-btn flat rounded no-caps color="red-5" label="Clear All Filters" class="full-width q-mb-lg" size="sm" icon="filter_alt_off" @click="clearFilters"></q-btn>
|
||||
|
||||
<div class="filter-drawer-section-title">Saved Views</div>
|
||||
<q-list dense class="q-gutter-y-xs">
|
||||
<q-item clickable v-ripple 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 class="filter-drawer-section-title q-mt-lg">Shift Availability</div>
|
||||
<div class="q-pa-sm rounded-borders" style="background: #f8fafc; border: 1px solid #e8ecf1; border-radius: 12px;">
|
||||
<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="blue-7" :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="blue-7" 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 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 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 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 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>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
146
dev/ui-ux/Opus w images/src/components/grid-cell/grid-cell.css
Normal file
146
dev/ui-ux/Opus w images/src/components/grid-cell/grid-cell.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/* grid-cell — individual shift cell with time ranges */
|
||||
|
||||
.grid-cell-root {
|
||||
width: var(--cell-width);
|
||||
min-width: var(--cell-width);
|
||||
height: 76px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: background 0.1s ease;
|
||||
padding: 3px 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
.grid-cell-root:hover:not(.cursor-not-allowed) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.grid-cell-compact {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Today column background */
|
||||
.grid-cell-today {
|
||||
background-color: #fafcff;
|
||||
}
|
||||
|
||||
/* Shift entry (time + activity) */
|
||||
.grid-cell-shift {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.grid-cell-shift-second {
|
||||
margin-top: 1px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
/* Time label */
|
||||
.grid-cell-time {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Activity badge */
|
||||
.grid-cell-activity-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 95%;
|
||||
text-align: center;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Special status (sick leave, off) */
|
||||
.grid-cell-special {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 92%;
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.grid-cell-special-emoji {
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.grid-cell-special-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid-cell-special-sub {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Lock overlay */
|
||||
.grid-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;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
/* Comment/Note indicators */
|
||||
.grid-cell-indicators {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 3px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.grid-cell-tooltip {
|
||||
padding: 10px 12px;
|
||||
max-width: 220px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Weekend */
|
||||
.grid-cell-bg-weekend {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* Reading mode highlights */
|
||||
.grid-cell-bg-reading-mode {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
.grid-cell-bg-reading-mode-intersection {
|
||||
background-color: #fff176 !important;
|
||||
}
|
||||
|
||||
/* Crosshair column hover */
|
||||
.grid-cell-col-hovered {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
box-shadow: inset 1px 0 0 var(--highlight-border), inset -1px 0 0 var(--highlight-border);
|
||||
z-index: 1;
|
||||
}
|
||||
153
dev/ui-ux/Opus w images/src/components/grid-cell/grid-cell.js
Normal file
153
dev/ui-ux/Opus w images/src/components/grid-cell/grid-cell.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* grid-cell.js
|
||||
* =============
|
||||
* A single shift cell inside an agent row.
|
||||
* Shows time ranges + activity labels, special statuses.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatDateForId,
|
||||
isWeekend,
|
||||
isToday,
|
||||
weekendsAreWorkingDays,
|
||||
isCompact,
|
||||
crosshairActive,
|
||||
hoveredDateStr,
|
||||
highlightedRowId,
|
||||
highlightedDateStr
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
getShifts,
|
||||
hasComment,
|
||||
hasNote,
|
||||
getCommentText,
|
||||
getNoteText,
|
||||
SPECIAL_STATUSES
|
||||
} from '../../services/data-service.js';
|
||||
|
||||
import { isCellLocked } from '../../services/socket-service.js';
|
||||
|
||||
export default {
|
||||
name: 'GridCell',
|
||||
props: {
|
||||
agentId: { type: Number, required: true },
|
||||
date: { type: Date, required: true }
|
||||
},
|
||||
emits: ['open-assignment'],
|
||||
setup(props, { emit }) {
|
||||
const today = Vue.computed(() => isToday(props.date));
|
||||
|
||||
const cellClass = Vue.computed(() => {
|
||||
const agentId = props.agentId;
|
||||
const date = props.date;
|
||||
const isRow = highlightedRowId.value === agentId;
|
||||
const isCol = highlightedDateStr.value === formatDateForId(date);
|
||||
const isHoverCol = crosshairActive.value && hoveredDateStr.value === formatDateForId(date);
|
||||
const isWknd = isWeekend(date) && !weekendsAreWorkingDays.value;
|
||||
const locked = isCellLocked(agentId, date);
|
||||
|
||||
const classes = ['grid-cell-root'];
|
||||
if (isCompact.value) classes.push('grid-cell-compact');
|
||||
if (isWknd) classes.push('grid-cell-bg-weekend');
|
||||
if (today.value) classes.push('grid-cell-today');
|
||||
if (isWknd && !weekendsAreWorkingDays.value) classes.push('cursor-not-allowed');
|
||||
else if (locked) classes.push('cursor-not-allowed');
|
||||
else classes.push('cursor-pointer');
|
||||
|
||||
if (isRow && isCol) classes.push('grid-cell-bg-reading-mode-intersection');
|
||||
else if (isRow || isCol) classes.push('grid-cell-bg-reading-mode');
|
||||
|
||||
if (isHoverCol) classes.push('grid-cell-col-hovered');
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const shifts = Vue.computed(() => getShifts(props.agentId, props.date));
|
||||
const locked = Vue.computed(() => isCellLocked(props.agentId, props.date));
|
||||
const showShift = Vue.computed(() => weekendsAreWorkingDays.value || !isWeekend(props.date));
|
||||
const comment = Vue.computed(() => hasComment(props.agentId, props.date));
|
||||
const note = Vue.computed(() => hasNote(props.agentId, props.date));
|
||||
const commentTxt = Vue.computed(() => getCommentText(props.agentId, props.date));
|
||||
const noteTxt = Vue.computed(() => getNoteText(props.agentId, props.date));
|
||||
|
||||
// Check if this is a special status (sick leave, off, etc.)
|
||||
const specialStatus = Vue.computed(() => {
|
||||
if (!shifts.value || shifts.value.length === 0) return null;
|
||||
const first = shifts.value[0];
|
||||
if (first.type === 'special') {
|
||||
return SPECIAL_STATUSES.find(s => s.id === first.statusId) || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Get regular shift entries only
|
||||
const regularShifts = Vue.computed(() => {
|
||||
if (!shifts.value) return [];
|
||||
return shifts.value.filter(s => s.type === 'shift');
|
||||
});
|
||||
|
||||
const onEnter = () => { hoveredDateStr.value = formatDateForId(props.date); };
|
||||
const onLeave = () => { hoveredDateStr.value = null; };
|
||||
const onClick = () => { emit('open-assignment', props.date); };
|
||||
|
||||
return {
|
||||
cellClass, shifts, regularShifts, specialStatus, locked, showShift,
|
||||
comment, note, commentTxt, noteTxt, today,
|
||||
onEnter, onLeave, onClick
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div :class="cellClass"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick">
|
||||
<template v-if="showShift">
|
||||
|
||||
<!-- Special Status (Sick Leave, Off, etc.) -->
|
||||
<div v-if="specialStatus" class="grid-cell-special"
|
||||
:style="{ backgroundColor: specialStatus.bgColor, borderColor: specialStatus.borderColor }">
|
||||
<div class="grid-cell-special-emoji" v-if="specialStatus.emoji">{{ specialStatus.emoji }}</div>
|
||||
<div class="grid-cell-special-label" :style="{ color: specialStatus.textColor }">{{ specialStatus.label }}</div>
|
||||
<div class="grid-cell-special-sub" :style="{ color: specialStatus.textColor }" v-if="specialStatus.id === 'sick_leave'">Full Day</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular Shifts -->
|
||||
<template v-else-if="regularShifts.length > 0">
|
||||
<div v-for="(shift, idx) in regularShifts" :key="idx" class="grid-cell-shift"
|
||||
:class="{ 'grid-cell-shift-second': idx > 0 }">
|
||||
<div class="grid-cell-time">{{ shift.timeLabel }}</div>
|
||||
<div class="grid-cell-activity-badge"
|
||||
:style="{ backgroundColor: shift.activityBgColor, color: shift.activityTextColor }">
|
||||
{{ shift.activityLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Lock overlay -->
|
||||
<div v-if="locked" class="grid-cell-locked-overlay">
|
||||
<q-icon name="lock" color="blue-grey-3" size="14px">
|
||||
<q-tooltip class="bg-grey-9 text-white shadow-4 q-pa-sm">This cell is currently being edited by another user.</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
|
||||
<!-- Comment / Note indicators -->
|
||||
<div class="grid-cell-indicators">
|
||||
<q-icon v-if="comment" name="chat_bubble" size="8px" color="blue-grey-3" class="cursor-help">
|
||||
<q-tooltip class="bg-white text-grey-9 border shadow-4 grid-cell-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-indigo-9 q-mb-xs">User Comment</div>
|
||||
<div class="text-caption text-grey-8">{{ commentTxt }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-if="note" name="info" size="8px" color="orange-4" class="cursor-help">
|
||||
<q-tooltip class="bg-white text-grey-9 border shadow-4 grid-cell-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-orange-9 q-mb-xs">Technical Note</div>
|
||||
<div class="text-caption text-grey-8">{{ noteTxt }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/* online-users — avatar stack + overflow list */
|
||||
|
||||
.online-users-stack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.online-users-avatar {
|
||||
margin-left: -8px;
|
||||
border: 2px solid white;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.online-users-avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.online-users-avatar:hover {
|
||||
transform: translateY(-2px);
|
||||
z-index: 10;
|
||||
border-color: #dbeafe;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* enter / leave transitions */
|
||||
.online-avatar-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.online-avatar-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.online-avatar-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) translateY(6px);
|
||||
}
|
||||
.online-avatar-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) translateY(-6px);
|
||||
}
|
||||
|
||||
/* +N overflow circle */
|
||||
.online-users-overflow {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: -8px;
|
||||
border-radius: 50%;
|
||||
background-color: #eff6ff;
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
z-index: 5;
|
||||
border: 2px solid white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.online-users-overflow:hover {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
/* count badge transition */
|
||||
.online-count-enter-active,
|
||||
.online-count-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.online-count-enter-from,
|
||||
.online-count-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
/* profile tooltip card */
|
||||
.online-users-profile-card {
|
||||
min-width: 260px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* online indicator dot */
|
||||
.online-users-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
animation: online-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes online-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* online-users.js
|
||||
* ================
|
||||
* Self-contained component showing online collaborators.
|
||||
* Desktop: avatar stack with animated enter/leave + overflow "+N" circle.
|
||||
* Mobile: icon button with badge and dropdown list.
|
||||
*
|
||||
* Reacts to WSS-driven changes in onlineUsers (from planner-state).
|
||||
*/
|
||||
|
||||
import {
|
||||
onlineUsers,
|
||||
visibleOnlineUsers,
|
||||
remainingOnlineCount
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
export default {
|
||||
name: 'OnlineUsers',
|
||||
setup() {
|
||||
return {
|
||||
onlineUsers,
|
||||
visibleOnlineUsers,
|
||||
remainingOnlineCount
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<!-- ============ Desktop avatar stack ============ -->
|
||||
<div class="row items-center gt-sm q-mr-sm">
|
||||
<div class="online-users-stack">
|
||||
|
||||
<transition-group name="online-avatar">
|
||||
<q-avatar
|
||||
v-for="user in visibleOnlineUsers"
|
||||
:key="user.id"
|
||||
size="32px"
|
||||
class="online-users-avatar"
|
||||
>
|
||||
<img :src="user.img">
|
||||
<q-tooltip
|
||||
anchor="bottom middle" self="top middle" :offset="[0, 10]"
|
||||
class="bg-white text-grey-9 shadow-10 q-pa-none rounded-borders"
|
||||
style="border: 1px solid #e2e8f0"
|
||||
>
|
||||
<div class="online-users-profile-card row no-wrap items-center">
|
||||
<q-avatar size="54px" class="q-mr-md shadow-1">
|
||||
<img :src="user.img">
|
||||
</q-avatar>
|
||||
<div class="column">
|
||||
<div class="text-weight-bold text-body2">{{ user.name }}</div>
|
||||
<div class="text-caption text-grey-6">{{ user.role }}</div>
|
||||
<div class="text-caption text-weight-medium" style="color: #10b981">
|
||||
<span class="online-users-status-dot"></span> Online Now
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-avatar>
|
||||
</transition-group>
|
||||
|
||||
<!-- +N overflow circle (only when more users than visible slots) -->
|
||||
<transition name="online-count">
|
||||
<div
|
||||
v-if="remainingOnlineCount > 0"
|
||||
class="online-users-overflow"
|
||||
>
|
||||
+{{ remainingOnlineCount }}
|
||||
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
|
||||
<q-list dense style="min-width: 260px">
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">
|
||||
Active Collaborators ({{ onlineUsers.length }})
|
||||
</q-item-label>
|
||||
<q-separator></q-separator>
|
||||
<q-scroll-area style="height: 260px;">
|
||||
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="28px"><img :src="user.img"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label>
|
||||
<q-item-label caption>{{ user.role }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<span class="online-users-status-dot"></span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-scroll-area>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Mobile button ============ -->
|
||||
<div class="lt-md">
|
||||
<q-btn round flat dense color="grey-6" icon="group">
|
||||
<q-badge v-if="onlineUsers.length > 0" color="teal" floating rounded>
|
||||
{{ onlineUsers.length }}
|
||||
</q-badge>
|
||||
<q-menu class="bg-white shadow-15" style="border: 1px solid #e2e8f0">
|
||||
<q-list dense style="min-width: 240px">
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-caption text-grey-6">
|
||||
Online Now ({{ onlineUsers.length }})
|
||||
</q-item-label>
|
||||
<q-separator></q-separator>
|
||||
<div v-if="onlineUsers.length === 0" class="q-pa-md text-center text-grey-5 text-caption">
|
||||
No collaborators online
|
||||
</div>
|
||||
<q-scroll-area v-else style="height: 250px;">
|
||||
<q-item v-for="user in onlineUsers" :key="user.id" clickable v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="28px"><img :src="user.img"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-bold text-caption">{{ user.name }}</q-item-label>
|
||||
<q-item-label caption>{{ user.role }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<span class="online-users-status-dot"></span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-scroll-area>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/* planner-grid — main scrollable grid area */
|
||||
|
||||
.planner-grid-viewport {
|
||||
height: calc(100vh - 56px) !important;
|
||||
overflow: auto !important;
|
||||
background: white;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 20px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.planner-grid-content {
|
||||
display: inline-block;
|
||||
min-width: fit-content;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.planner-grid-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Left column — agent cards */
|
||||
.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 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.planner-grid-left-col-header {
|
||||
z-index: 58;
|
||||
background: #fdfdfd;
|
||||
padding-right: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.planner-grid-left-col {
|
||||
padding: 0 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.planner-grid-cells-area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Sticky header rows */
|
||||
.planner-grid-row-eod {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
height: var(--h-eod);
|
||||
background: #fffafb;
|
||||
}
|
||||
.planner-grid-row-eod .planner-grid-left-col {
|
||||
z-index: 60;
|
||||
background: #fffafb;
|
||||
color: #db2777;
|
||||
border-bottom: 1px solid #fce7f3;
|
||||
}
|
||||
|
||||
.planner-grid-row-status {
|
||||
position: sticky;
|
||||
top: var(--h-eod);
|
||||
z-index: 39;
|
||||
height: var(--h-status);
|
||||
background: white;
|
||||
}
|
||||
.planner-grid-row-status .planner-grid-left-col {
|
||||
z-index: 59;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.planner-grid-row-dates {
|
||||
position: sticky;
|
||||
top: calc(var(--h-eod) + var(--h-status));
|
||||
z-index: 38;
|
||||
height: var(--h-dates);
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
/* Group headers */
|
||||
.planner-grid-header-l1 {
|
||||
background: #eff6ff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.06em;
|
||||
height: 36px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
border-bottom: 1px solid #dbeafe;
|
||||
}
|
||||
.planner-grid-header-l1 .planner-grid-left-col {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.planner-grid-header-l2 {
|
||||
background: #f8fafc;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.04em;
|
||||
height: 32px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.planner-grid-header-l2 .planner-grid-left-col {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* Agent cell / card */
|
||||
.planner-grid-agent-cell {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.15s ease;
|
||||
padding: 6px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
.planner-grid-agent-cell:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.planner-grid-compact {
|
||||
height: 50px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Blue selection bar on left edge */
|
||||
.planner-grid-select-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
background: #2563eb;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
/* Avatar wrapper for status dot positioning */
|
||||
.planner-grid-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.planner-grid-agent-avatar {
|
||||
border: 2px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Agent name */
|
||||
.planner-grid-agent-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Badges row */
|
||||
.planner-grid-agent-badges {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Row highlight (persistent reading helper) */
|
||||
.planner-grid-row-highlighted .planner-grid-left-col {
|
||||
background-color: #fffde7 !important;
|
||||
}
|
||||
|
||||
/* Selected agent row */
|
||||
.planner-grid-row-selected .planner-grid-agent-cell {
|
||||
background: #eff6ff !important;
|
||||
}
|
||||
|
||||
/* Agent row hover triggers for highlight button */
|
||||
.planner-grid-agent-cell .planner-grid-highlight-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.planner-grid-agent-cell:hover .planner-grid-highlight-btn,
|
||||
.planner-grid-highlight-btn.planner-grid-is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Crosshair row highlight on hover */
|
||||
.planner-grid-row-item.planner-grid-crosshair-enabled:hover {
|
||||
background-color: var(--highlight-bg);
|
||||
box-shadow: inset 0 1px 0 var(--highlight-border), inset 0 -1px 0 var(--highlight-border);
|
||||
}
|
||||
.planner-grid-row-item.planner-grid-crosshair-enabled:hover .planner-grid-left-col {
|
||||
background-color: #eff6ff !important;
|
||||
border-top: 1px solid var(--highlight-border);
|
||||
border-bottom: 1px solid var(--highlight-border);
|
||||
}
|
||||
|
||||
/* EOD / availability cell (inside header rows) */
|
||||
.planner-grid-eod-cell {
|
||||
width: var(--cell-width);
|
||||
min-width: var(--cell-width);
|
||||
border-right: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Search + controls container */
|
||||
.planner-grid-search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.planner-grid-search-input {
|
||||
flex: 1;
|
||||
}
|
||||
.planner-grid-search-input .q-field__control {
|
||||
height: 34px !important;
|
||||
}
|
||||
|
||||
/* Filter button */
|
||||
.planner-grid-filter-btn-active {
|
||||
background-color: #dbeafe !important;
|
||||
border: 1px solid #93c5fd !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.planner-grid-filter-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background-color: #f59e0b;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
box-shadow: 0 0 0 2px #dbeafe;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Dense date card for date picker */
|
||||
.planner-grid-dense-date-card {
|
||||
width: 290px;
|
||||
}
|
||||
.planner-grid-dense-date-card .q-date {
|
||||
width: 100%;
|
||||
min-height: unset;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* planner-grid.js
|
||||
* =================
|
||||
* Main grid area: EOD targets row, availability row, date header row,
|
||||
* loading skeleton, QVirtualScroll with group headers + agent rows.
|
||||
* Agent rows feature rich cards with badges, status dots, and filters.
|
||||
*/
|
||||
|
||||
const { ref, nextTick } = Vue;
|
||||
|
||||
import {
|
||||
dates,
|
||||
gridStyles,
|
||||
showEodTargets,
|
||||
showAvailability,
|
||||
crosshairActive,
|
||||
hoveredDateStr,
|
||||
formatDateForId,
|
||||
isCompact,
|
||||
highlightedRowId,
|
||||
highlightedDateStr,
|
||||
toggleRowHighlight,
|
||||
search,
|
||||
filterDrawer,
|
||||
isFilterActive,
|
||||
activeFilterCount,
|
||||
dateMenu,
|
||||
proxyDate,
|
||||
syncProxyDate,
|
||||
pickerStartDay,
|
||||
startDate,
|
||||
getStartOfWeek,
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedAgent,
|
||||
selectedDate,
|
||||
pendingShift,
|
||||
weekendsAreWorkingDays,
|
||||
isWeekend
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
loading,
|
||||
flattenedList,
|
||||
getSkillBadgeColor
|
||||
} from '../../services/data-service.js';
|
||||
|
||||
import { isCellLocked } from '../../services/socket-service.js';
|
||||
|
||||
import DateHeader from '../date-header/date-header.js';
|
||||
import GridCell from '../grid-cell/grid-cell.js';
|
||||
|
||||
export default {
|
||||
name: 'PlannerGrid',
|
||||
components: { DateHeader, GridCell },
|
||||
setup() {
|
||||
const viewport = ref(null);
|
||||
|
||||
const applyDateSelection = () => {
|
||||
if (!proxyDate.value) return;
|
||||
const target = new Date(proxyDate.value);
|
||||
startDate.value = getStartOfWeek(target);
|
||||
if (rightDrawer.value && editMode.value === 'assignment' && selectedAgent.value) {
|
||||
selectedDate.value = target;
|
||||
pendingShift.value = null;
|
||||
}
|
||||
dateMenu.value = false;
|
||||
nextTick(() => { if (viewport.value) viewport.value.scrollLeft = 0; });
|
||||
};
|
||||
|
||||
const openAssignment = (agent, date) => {
|
||||
if (isCellLocked(agent.id, date) || (!weekendsAreWorkingDays.value && isWeekend(date))) return;
|
||||
editMode.value = 'assignment';
|
||||
selectedAgent.value = agent;
|
||||
selectedDate.value = date;
|
||||
pendingShift.value = null;
|
||||
rightDrawer.value = true;
|
||||
};
|
||||
|
||||
const openProfile = (agent) => {
|
||||
editMode.value = 'profile';
|
||||
selectedAgent.value = { ...agent };
|
||||
selectedDate.value = null;
|
||||
rightDrawer.value = true;
|
||||
};
|
||||
|
||||
return {
|
||||
viewport,
|
||||
dates,
|
||||
gridStyles,
|
||||
showEodTargets,
|
||||
showAvailability,
|
||||
crosshairActive,
|
||||
hoveredDateStr,
|
||||
formatDateForId,
|
||||
isCompact,
|
||||
highlightedRowId,
|
||||
highlightedDateStr,
|
||||
toggleRowHighlight,
|
||||
search,
|
||||
filterDrawer,
|
||||
isFilterActive,
|
||||
activeFilterCount,
|
||||
dateMenu,
|
||||
proxyDate,
|
||||
syncProxyDate,
|
||||
pickerStartDay,
|
||||
loading,
|
||||
flattenedList,
|
||||
getSkillBadgeColor,
|
||||
selectedAgent,
|
||||
applyDateSelection,
|
||||
openAssignment,
|
||||
openProfile
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-page class="q-pa-none">
|
||||
<!-- LOADING OVERLAY -->
|
||||
<q-inner-loading :showing="loading">
|
||||
<q-spinner-gears size="50px" color="blue-7"></q-spinner-gears>
|
||||
<div class="text-blue-7 q-mt-sm" style="font-weight: 600">Loading Agents...</div>
|
||||
</q-inner-loading>
|
||||
|
||||
<!-- Main Scrollable Area -->
|
||||
<div id="viewport-target"
|
||||
class="planner-grid-viewport"
|
||||
ref="viewport"
|
||||
:style="gridStyles">
|
||||
<div class="planner-grid-content">
|
||||
|
||||
<!-- EOD Targets Row -->
|
||||
<div v-if="showEodTargets" class="planner-grid-row planner-grid-row-eod">
|
||||
<div class="planner-grid-left-col text-overline text-weight-bold">EOD Targets</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div v-for="(d, i) in dates" :key="'e'+i"
|
||||
class="planner-grid-eod-cell"
|
||||
:class="{'grid-cell-col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}"
|
||||
style="height: var(--h-eod)">
|
||||
<div class="text-weight-bold" style="font-size: 10px; color: #db2777">94%</div>
|
||||
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded style="width: 60%"></q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Availability Row -->
|
||||
<div v-if="showAvailability" class="planner-grid-row planner-grid-row-status">
|
||||
<div class="planner-grid-left-col text-overline text-grey-5">Availability</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div v-for="(d, i) in dates" :key="'s'+i"
|
||||
class="planner-grid-eod-cell"
|
||||
:class="{'grid-cell-col-hovered': crosshairActive && hoveredDateStr === formatDateForId(d)}"
|
||||
style="height: var(--h-status)">
|
||||
<q-icon name="circle"
|
||||
:color="i % 10 === 0 ? 'red-3' : (i % 5 === 0 ? 'orange-3' : 'green-3')"
|
||||
size="8px"></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Header Row -->
|
||||
<div class="planner-grid-row planner-grid-row-dates">
|
||||
<div class="planner-grid-left-col planner-grid-left-col-header">
|
||||
<div class="planner-grid-search-container">
|
||||
<q-input v-model="search" debounce="300" dense outlined rounded
|
||||
placeholder="Search agents..."
|
||||
class="planner-grid-search-input" 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 ? 'blue-7' : 'grey-5'"
|
||||
:class="isFilterActive ? 'planner-grid-filter-btn-active' : ''"
|
||||
size="sm"
|
||||
@click="filterDrawer = !filterDrawer">
|
||||
<div v-if="isFilterActive" class="planner-grid-filter-status-dot"></div>
|
||||
<q-tooltip>{{ isFilterActive ? activeFilterCount + ' filters active' : 'Advanced Filters' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense icon="calendar_today" color="blue-7" size="sm">
|
||||
<q-menu v-model="dateMenu" @show="syncProxyDate">
|
||||
<q-card class="planner-grid-dense-date-card shadow-12 border">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-date v-model="proxyDate" minimal flat color="blue-7" :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="blue-7" class="q-px-md" @click="applyDateSelection"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<date-header v-for="(date, i) in dates" :key="'d'+i" :date="date"></date-header>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOADING SKELETON -->
|
||||
<div v-if="loading" class="q-pa-none">
|
||||
<div v-for="n in 14" :key="n" class="planner-grid-row">
|
||||
<div class="planner-grid-left-col border-b" style="height: 76px">
|
||||
<div class="row items-center full-width q-px-xs">
|
||||
<q-skeleton type="QAvatar" size="36px" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<q-skeleton type="text" width="70%" />
|
||||
<div class="row q-gutter-xs q-mt-xs">
|
||||
<q-skeleton type="rect" width="50px" height="16px" class="rounded-borders" />
|
||||
<q-skeleton type="rect" width="40px" height="16px" class="rounded-borders" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div v-for="c in dates.length" :key="c" class="planner-grid-eod-cell" style="width:var(--cell-width);min-width:var(--cell-width);height:76px">
|
||||
<q-skeleton type="rect" height="16px" width="80%" class="rounded-borders" />
|
||||
<q-skeleton type="rect" height="14px" width="60%" class="rounded-borders q-mt-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIRTUAL SCROLL -->
|
||||
<q-virtual-scroll
|
||||
v-else
|
||||
scroll-target="#viewport-target"
|
||||
:items="flattenedList"
|
||||
:item-size="isCompact ? 50 : 76"
|
||||
class="virtual-scroll-target"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<!-- Level 1 Header -->
|
||||
<div v-if="item.type === 'header-l1'" :key="item.id"
|
||||
class="planner-grid-row planner-grid-header-l1">
|
||||
<div class="planner-grid-left-col">{{ item.label }}</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div class="planner-grid-eod-cell" style="flex: 1; height: 36px; border-right: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level 2 Header -->
|
||||
<div v-else-if="item.type === 'header-l2'" :key="item.id"
|
||||
class="planner-grid-row planner-grid-header-l2">
|
||||
<div class="planner-grid-left-col">{{ item.label }}</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div class="planner-grid-eod-cell" style="flex: 1; height: 32px; border-right: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Row -->
|
||||
<div v-else :key="item.id"
|
||||
class="planner-grid-row planner-grid-row-item"
|
||||
:class="{
|
||||
'planner-grid-row-highlighted': highlightedRowId === item.data.id,
|
||||
'planner-grid-crosshair-enabled': crosshairActive,
|
||||
'planner-grid-row-selected': selectedAgent && selectedAgent.id === item.data.id
|
||||
}">
|
||||
<!-- Agent Card (left column) -->
|
||||
<div class="planner-grid-left-col planner-grid-agent-cell"
|
||||
:class="[isCompact ? 'planner-grid-compact' : '']"
|
||||
@click="openProfile(item.data)">
|
||||
|
||||
<!-- Selection indicator bar -->
|
||||
<div v-if="selectedAgent && selectedAgent.id === item.data.id" class="planner-grid-select-bar"></div>
|
||||
|
||||
<!-- Avatar with status dot -->
|
||||
<div class="planner-grid-avatar-wrap">
|
||||
<q-avatar :size="isCompact ? '28px' : '38px'" class="planner-grid-agent-avatar">
|
||||
<img :src="item.data.avatar">
|
||||
</q-avatar>
|
||||
<span class="status-dot"
|
||||
:class="'status-dot--' + item.data.status"
|
||||
:style="isCompact ? 'width:8px;height:8px' : ''"></span>
|
||||
</div>
|
||||
|
||||
<!-- Name + Badges -->
|
||||
<div class="q-ml-sm overflow-hidden col">
|
||||
<div class="planner-grid-agent-name" :style="{fontSize: isCompact ? '11px' : '13px'}">{{ item.data.name }}</div>
|
||||
<div v-if="!isCompact" class="planner-grid-agent-badges">
|
||||
<span v-for="badge in item.data.displayBadges" :key="badge"
|
||||
class="skill-badge"
|
||||
:class="'skill-badge--' + getSkillBadgeColor(badge)">
|
||||
{{ badge }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlight button -->
|
||||
<q-btn round flat dense
|
||||
:icon="highlightedRowId === item.data.id ? 'visibility_off' : 'visibility'"
|
||||
:color="highlightedRowId === item.data.id ? 'amber-9' : 'grey-4'"
|
||||
size="sm"
|
||||
class="planner-grid-highlight-btn"
|
||||
:class="{ 'planner-grid-is-active': highlightedRowId === item.data.id }"
|
||||
@click.stop="toggleRowHighlight(item.data.id)">
|
||||
<q-tooltip>{{ highlightedRowId === item.data.id ? 'Turn off reading mode' : 'Highlight Row' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Grid cells -->
|
||||
<div class="planner-grid-cells-area">
|
||||
<grid-cell v-for="(date, i) in dates"
|
||||
:key="'c'+item.data.id+i"
|
||||
:agent-id="item.data.id"
|
||||
:date="date"
|
||||
@open-assignment="openAssignment(item.data, $event)">
|
||||
</grid-cell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/* workspace-drawer — left settings panel */
|
||||
|
||||
.workspace-drawer {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.workspace-drawer-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workspace-drawer-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.workspace-drawer-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* workspace-drawer.js
|
||||
* ====================
|
||||
* Left drawer: timeline range, date-picker start, dev tools,
|
||||
* grid visibility toggles, compact/weekend switches.
|
||||
*/
|
||||
|
||||
const { nextTick } = Vue;
|
||||
|
||||
import {
|
||||
leftDrawer,
|
||||
viewScope,
|
||||
pickerStartDay,
|
||||
crosshairActive,
|
||||
showEodTargets,
|
||||
showAvailability,
|
||||
isCompact,
|
||||
weekendsAreWorkingDays,
|
||||
startDate,
|
||||
getStartOfWeek,
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedDate,
|
||||
pendingShift
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import { simulateWssLock } from '../../services/socket-service.js';
|
||||
|
||||
const { useQuasar } = Quasar;
|
||||
|
||||
export default {
|
||||
name: 'WorkspaceDrawer',
|
||||
setup() {
|
||||
const $q = useQuasar();
|
||||
|
||||
const doSimulate = () => simulateWssLock($q);
|
||||
|
||||
const resetToToday = () => {
|
||||
const today = new Date();
|
||||
startDate.value = getStartOfWeek(today);
|
||||
if (rightDrawer.value && editMode.value === 'assignment') {
|
||||
selectedDate.value = today;
|
||||
pendingShift.value = null;
|
||||
}
|
||||
leftDrawer.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
leftDrawer,
|
||||
viewScope,
|
||||
pickerStartDay,
|
||||
crosshairActive,
|
||||
showEodTargets,
|
||||
showAvailability,
|
||||
isCompact,
|
||||
weekendsAreWorkingDays,
|
||||
doSimulate,
|
||||
resetToToday
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer v-model="leftDrawer" side="left" bordered :width="300" class="workspace-drawer">
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="workspace-drawer-title">Workspace</div>
|
||||
<div class="workspace-drawer-subtitle">Manage grid and picker preferences.</div>
|
||||
|
||||
<div class="column q-gutter-y-lg">
|
||||
<div>
|
||||
<div class="workspace-drawer-label">Timeline Range</div>
|
||||
<q-select v-model="viewScope" :options="[4, 8, 12]" outlined dense rounded emit-value map-options suffix=" Weeks" bg-color="white"></q-select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="workspace-drawer-label">Date Picker Week Start</div>
|
||||
<q-select v-model="pickerStartDay" :options="[{label: 'Sunday', value: 0}, {label: 'Monday', value: 1}]" outlined dense rounded emit-value map-options bg-color="white"></q-select>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<!-- Dev Tools -->
|
||||
<div>
|
||||
<div class="workspace-drawer-label">Dev Tools</div>
|
||||
<q-btn outline rounded color="purple-7" label="Simulate WSS Lock" class="full-width" icon="lock" no-caps @click="doSimulate">
|
||||
<q-tooltip>Simulate receiving a "Lock Cell" message from server</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<div>
|
||||
<div class="workspace-drawer-label">Grid Visibility</div>
|
||||
<q-item tag="label" v-ripple class="q-px-none rounded-borders">
|
||||
<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="blue-7"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple class="q-px-none rounded-borders">
|
||||
<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 rounded-borders">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Show Availability</q-item-label><q-item-label caption>Traffic light indicators</q-item-label></q-item-section>
|
||||
<q-item-section side><q-toggle v-model="showAvailability" color="orange-6"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none rounded-borders">
|
||||
<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="blue-7"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple class="q-px-none rounded-borders">
|
||||
<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="blue-7"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-btn outline rounded no-caps color="blue-7" label="Reset to Today" icon="today" class="full-width q-mt-md" @click="resetToToday"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
53
dev/ui-ux/Opus w images/src/main.js
Normal file
53
dev/ui-ux/Opus w images/src/main.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* main.js
|
||||
* ========
|
||||
* Application entry point.
|
||||
* Registers all components, initializes Quasar, and mounts the app.
|
||||
*/
|
||||
|
||||
const { createApp, onMounted } = Vue;
|
||||
const { useQuasar } = Quasar;
|
||||
|
||||
/* ---- Import components ---- */
|
||||
import AppHeader from './components/app-header/app-header.js';
|
||||
import WorkspaceDrawer from './components/workspace-drawer/workspace-drawer.js';
|
||||
import FilterDrawer from './components/filter-drawer/filter-drawer.js';
|
||||
import AssignmentDrawer from './components/assignment-drawer/assignment-drawer.js';
|
||||
import PlannerGrid from './components/planner-grid/planner-grid.js';
|
||||
|
||||
/* ---- Import services (for lifecycle) ---- */
|
||||
import { loadDataFromDatabase } from './services/data-service.js';
|
||||
import { startOnlineUsersSimulation } from './services/socket-service.js';
|
||||
|
||||
/* ---- Root App ---- */
|
||||
const app = createApp({
|
||||
components: {
|
||||
AppHeader,
|
||||
WorkspaceDrawer,
|
||||
FilterDrawer,
|
||||
AssignmentDrawer,
|
||||
PlannerGrid
|
||||
},
|
||||
setup() {
|
||||
const $q = useQuasar();
|
||||
|
||||
onMounted(() => {
|
||||
loadDataFromDatabase($q);
|
||||
startOnlineUsersSimulation($q);
|
||||
});
|
||||
},
|
||||
template: `
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<app-header></app-header>
|
||||
<workspace-drawer></workspace-drawer>
|
||||
<filter-drawer></filter-drawer>
|
||||
<assignment-drawer></assignment-drawer>
|
||||
<q-page-container>
|
||||
<planner-grid></planner-grid>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
`
|
||||
});
|
||||
|
||||
app.use(Quasar, { config: { brand: { primary: '#2563eb' } } });
|
||||
app.mount('#q-app');
|
||||
441
dev/ui-ux/Opus w images/src/services/data-service.js
Normal file
441
dev/ui-ux/Opus w images/src/services/data-service.js
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* data-service.js
|
||||
* ================
|
||||
* Domain constants, mock-data generation, and reactive data stores
|
||||
* for agents, assignments, comments, notes, holidays, and special days.
|
||||
*/
|
||||
|
||||
const { ref, computed, reactive } = Vue;
|
||||
|
||||
import {
|
||||
formatDateForId,
|
||||
startDate,
|
||||
weekendsAreWorkingDays,
|
||||
isWeekend,
|
||||
filterByAvailability,
|
||||
filterDate,
|
||||
filterShiftTypes,
|
||||
search,
|
||||
activeDept,
|
||||
activeHub,
|
||||
filterRoles,
|
||||
filterSkills
|
||||
} from './planner-state.js';
|
||||
|
||||
/* ===== CONSTANTS ===== */
|
||||
|
||||
export const HUBS = [
|
||||
{ id: 'DE', name: 'GERMANY HUB (DE)' },
|
||||
{ id: 'IT', name: 'ITALY HUB (IT)' },
|
||||
{ id: 'FR', name: 'FRANCE HUB (FR)' },
|
||||
{ id: 'GB', name: 'UNITED KINGDOM (GB)' },
|
||||
{ id: 'ES', name: 'SPAIN HUB (ES)' },
|
||||
{ id: 'AE', name: 'UAE HUB (AE)' },
|
||||
{ id: 'PL', name: 'POLAND HUB (PL)' }
|
||||
];
|
||||
|
||||
export const DEPARTMENTS = ['Support', 'Technical', 'Sales', 'VIP', 'Billing'];
|
||||
export const ROLES = ['Senior Lead', 'Specialist', 'Agent'];
|
||||
export const SKILLS = ['English', 'German', 'French', 'Molecular App', 'Hardware', 'Billing Specialist', 'VIP Concierge', 'Technical Training'];
|
||||
|
||||
/* Activity types with colors matching screenshot */
|
||||
export const ACTIVITY_TYPES = [
|
||||
{ id: 'main_line', label: 'Main Line', badgeColor: 'blue', textColor: '#1d4ed8', bgColor: '#dbeafe' },
|
||||
{ id: 'french', label: 'French', badgeColor: 'pink', textColor: '#db2777', bgColor: '#fce7f3' },
|
||||
{ id: 'spanish', label: 'Spanish', badgeColor: 'green', textColor: '#15803d', bgColor: '#dcfce7' },
|
||||
{ id: 'escalations', label: 'Escalations', badgeColor: 'orange', textColor: '#c2410c', bgColor: '#ffedd5' },
|
||||
{ id: 'email', label: 'Email', badgeColor: 'gray', textColor: '#64748b', bgColor: '#f1f5f9' },
|
||||
{ id: 'training', label: 'Training', badgeColor: 'purple', textColor: '#7c3aed', bgColor: '#f3e8ff' },
|
||||
{ id: 'shadowing', label: 'Shadowing', badgeColor: 'teal', textColor: '#0d9488', bgColor: '#ccfbf1' },
|
||||
];
|
||||
|
||||
/* Shift time templates */
|
||||
export const SHIFT_TEMPLATES = [
|
||||
{ id: 'morning_8_16', timeStart: '08:00', timeEnd: '16:00', label: '08:00 - 16:00' },
|
||||
{ id: 'morning_7_15', timeStart: '07:00', timeEnd: '15:00', label: '07:00 - 15:00' },
|
||||
{ id: 'day_9_17', timeStart: '09:00', timeEnd: '17:00', label: '09:00 - 17:00' },
|
||||
{ id: 'afternoon_10_18',timeStart: '10:00', timeEnd: '18:00', label: '10:00 - 18:00' },
|
||||
{ id: 'late_12_20', timeStart: '12:00', timeEnd: '20:00', label: '12:00 - 20:00' },
|
||||
];
|
||||
|
||||
/* Special day statuses */
|
||||
export const SPECIAL_STATUSES = [
|
||||
{ id: 'sick_leave', label: 'Sick Leave', emoji: '🤒', bgColor: '#fef9c3', textColor: '#854d0e', borderColor: '#fde047' },
|
||||
{ id: 'vacation', label: 'Vacation', emoji: '🏖️', bgColor: '#e0f2fe', textColor: '#0369a1', borderColor: '#7dd3fc' },
|
||||
{ id: 'off', label: 'Off', emoji: '', bgColor: '#f8fafc', textColor: '#94a3b8', borderColor: '#e2e8f0' },
|
||||
];
|
||||
|
||||
/* Legacy SHIFTS for backward compat with assignment drawer buttons */
|
||||
export const SHIFTS = [
|
||||
{ id: 'm', label: 'MORNING', color: 'green-7', badgeClass: 'bg-green-1 text-green-9 border-green-2' },
|
||||
{ id: 'a', label: 'AFTERNOON', color: 'blue-7', badgeClass: 'bg-blue-1 text-blue-9 border-blue-2' },
|
||||
{ id: 'h', label: 'HOTLINE', color: 'indigo-7', badgeClass: 'bg-indigo-1 text-indigo-9 border-indigo-2' },
|
||||
{ id: 'e', label: 'EOD ONLY', color: 'pink-7', badgeClass: 'bg-pink-1 text-pink-9 border-pink-2' }
|
||||
];
|
||||
|
||||
export const hubOptions = [
|
||||
{ label: 'All Hubs', value: 'All' },
|
||||
...HUBS.map(h => ({ label: h.name, value: h.id }))
|
||||
];
|
||||
|
||||
/* Skill badge color mapping */
|
||||
const SKILL_BADGE_COLORS = {
|
||||
'L2 Support': 'blue',
|
||||
'Lead': 'green',
|
||||
'French': 'red',
|
||||
'Spanish': 'teal',
|
||||
'Trainee': 'gray',
|
||||
'German': 'orange',
|
||||
'English': 'indigo',
|
||||
'Hardware': 'purple',
|
||||
'VIP Concierge': 'pink',
|
||||
'Billing Specialist': 'orange',
|
||||
'Technical Training': 'purple',
|
||||
'Molecular App': 'teal',
|
||||
'Senior Lead': 'green',
|
||||
'Specialist': 'blue',
|
||||
'Agent': 'gray',
|
||||
};
|
||||
|
||||
export const getSkillBadgeColor = (skill) => SKILL_BADGE_COLORS[skill] || 'gray';
|
||||
|
||||
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.'];
|
||||
|
||||
/* Agent names for realistic display */
|
||||
const FIRST_NAMES = ['Sarah', 'David', 'Emily', 'Michael', 'Linda', 'James', 'Maria', 'Thomas', 'Anna', 'Robert', 'Sophie', 'Daniel', 'Laura', 'Chris', 'Julia', 'Patrick', 'Nicole', 'Stefan', 'Sandra', 'Markus'];
|
||||
const LAST_NAMES = ['Jenkins', 'Chen', 'Davis', 'Brown', 'Kim', 'Wilson', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Moore', 'Jackson', 'White', 'Harris', 'Clark', 'Lewis', 'Robinson', 'Walker', 'Hall', 'Allen'];
|
||||
|
||||
/* Role badges */
|
||||
const ROLE_BADGES = ['L2 Support', 'Lead', 'Trainee', 'L2 Support', 'Lead'];
|
||||
|
||||
/* ===== REACTIVE DATA STORES ===== */
|
||||
|
||||
export const agents = ref([]);
|
||||
export const loading = ref(false);
|
||||
export const assignments = reactive({}); // agentId -> dateStr -> array of shift objects
|
||||
export const comments = reactive({});
|
||||
export const notes = reactive({});
|
||||
export const holidays = reactive({});
|
||||
export const specialDays = reactive({});
|
||||
|
||||
/* ===== MOCK GENERATOR ===== */
|
||||
|
||||
const generateMockAgents = (count) => {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const hub = HUBS[Math.floor(Math.random() * HUBS.length)];
|
||||
const firstName = FIRST_NAMES[i % FIRST_NAMES.length];
|
||||
const lastName = LAST_NAMES[i % LAST_NAMES.length];
|
||||
const role = ROLES[i % ROLES.length];
|
||||
const mainSkills = [SKILLS[i % SKILLS.length], SKILLS[(i + 2) % SKILLS.length]];
|
||||
|
||||
// Generate display badges (role badge + language/skill badges)
|
||||
const roleBadge = ROLE_BADGES[i % ROLE_BADGES.length];
|
||||
const languageBadges = mainSkills.filter(s => ['French', 'German', 'English'].includes(s));
|
||||
const displayBadges = [roleBadge, ...languageBadges].slice(0, 2);
|
||||
|
||||
// Status: most online, some away, few busy
|
||||
const statusRand = Math.random();
|
||||
const status = statusRand < 0.6 ? 'online' : (statusRand < 0.85 ? 'away' : 'busy');
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
name: `${firstName} ${lastName}`,
|
||||
dept: DEPARTMENTS[i % DEPARTMENTS.length],
|
||||
role: role,
|
||||
hub: hub.id,
|
||||
hubName: hub.name,
|
||||
skills: mainSkills,
|
||||
displayBadges: displayBadges,
|
||||
status: status,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${firstName}${lastName}${i}`
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/* ===== GENERATE SHIFT ASSIGNMENTS ===== */
|
||||
|
||||
const generateShiftsForAgent = (agent, mockStartDate) => {
|
||||
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);
|
||||
const dayOfWeek = d.getDay();
|
||||
|
||||
// Skip weekends
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
|
||||
|
||||
const shifts = [];
|
||||
|
||||
// Determine shift pattern based on agent + day
|
||||
const pattern = (agent.id + i) % 12;
|
||||
|
||||
if (pattern === 0) {
|
||||
// Sick Leave
|
||||
assignments[agent.id][dStr] = [{
|
||||
type: 'special',
|
||||
statusId: 'sick_leave',
|
||||
label: 'Sick Leave',
|
||||
fullDay: true
|
||||
}];
|
||||
} else if (pattern === 11) {
|
||||
// Off day
|
||||
assignments[agent.id][dStr] = [{
|
||||
type: 'special',
|
||||
statusId: 'off',
|
||||
label: 'Off',
|
||||
fullDay: true
|
||||
}];
|
||||
} else {
|
||||
// Regular shifts - pick 1-2 shifts per day
|
||||
const shiftTemplate = SHIFT_TEMPLATES[pattern % SHIFT_TEMPLATES.length];
|
||||
const activityType = ACTIVITY_TYPES[pattern % ACTIVITY_TYPES.length];
|
||||
|
||||
shifts.push({
|
||||
type: 'shift',
|
||||
timeStart: shiftTemplate.timeStart,
|
||||
timeEnd: shiftTemplate.timeEnd,
|
||||
timeLabel: shiftTemplate.label,
|
||||
activityId: activityType.id,
|
||||
activityLabel: activityType.label,
|
||||
activityColor: activityType.badgeColor,
|
||||
activityBgColor: activityType.bgColor,
|
||||
activityTextColor: activityType.textColor,
|
||||
});
|
||||
|
||||
// Some agents get a second shift
|
||||
if (pattern % 4 === 0 && pattern !== 0) {
|
||||
const secondActivity = ACTIVITY_TYPES[(pattern + 3) % ACTIVITY_TYPES.length];
|
||||
shifts.push({
|
||||
type: 'shift',
|
||||
timeStart: '09:00',
|
||||
timeEnd: '17:00',
|
||||
timeLabel: '09:00 - 17:00',
|
||||
activityId: secondActivity.id,
|
||||
activityLabel: secondActivity.label,
|
||||
activityColor: secondActivity.badgeColor,
|
||||
activityBgColor: secondActivity.bgColor,
|
||||
activityTextColor: secondActivity.textColor,
|
||||
});
|
||||
}
|
||||
|
||||
// Training / Shadowing for some
|
||||
if (pattern === 5) {
|
||||
shifts[0].activityId = 'training';
|
||||
shifts[0].activityLabel = 'Training';
|
||||
shifts[0].activityColor = 'purple';
|
||||
shifts[0].activityBgColor = '#f3e8ff';
|
||||
shifts[0].activityTextColor = '#7c3aed';
|
||||
shifts[0].timeStart = '12:00';
|
||||
shifts[0].timeEnd = '20:00';
|
||||
shifts[0].timeLabel = '12:00 - 20:00';
|
||||
}
|
||||
if (pattern === 6) {
|
||||
shifts[0].activityId = 'shadowing';
|
||||
shifts[0].activityLabel = 'Shadowing';
|
||||
shifts[0].activityColor = 'teal';
|
||||
shifts[0].activityBgColor = '#ccfbf1';
|
||||
shifts[0].activityTextColor = '#0d9488';
|
||||
shifts[0].timeStart = '12:00';
|
||||
shifts[0].timeEnd = '20:00';
|
||||
shifts[0].timeLabel = '12:00 - 20:00';
|
||||
}
|
||||
|
||||
assignments[agent.id][dStr] = shifts;
|
||||
}
|
||||
|
||||
// Comments & notes
|
||||
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];
|
||||
}
|
||||
};
|
||||
|
||||
/* ===== DATABASE LOADER (mock) ===== */
|
||||
|
||||
export const loadDataFromDatabase = ($q) => {
|
||||
loading.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
const fetchedAgents = generateMockAgents(800);
|
||||
agents.value = fetchedAgents;
|
||||
|
||||
const mockStartDate = new Date(startDate.value);
|
||||
|
||||
fetchedAgents.forEach(agent => {
|
||||
generateShiftsForAgent(agent, mockStartDate);
|
||||
});
|
||||
|
||||
// Dynamic holidays / special days relative to today
|
||||
for (const key in holidays) delete holidays[key];
|
||||
for (const key in specialDays) delete specialDays[key];
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
holidays[formatDateForId(tomorrow)] = 'Regional Holiday';
|
||||
|
||||
const eventDay = new Date(today);
|
||||
eventDay.setDate(today.getDate() + 5);
|
||||
specialDays[formatDateForId(eventDay)] = 'Quarterly Planning';
|
||||
|
||||
loading.value = false;
|
||||
$q.notify({ message: 'Data Loaded: 800 Agents', color: 'positive', position: 'top', timeout: 1000 });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
/* ===== ACCESSORS ===== */
|
||||
|
||||
/** Get all shift entries for an agent on a date (returns array or null) */
|
||||
export const getShifts = (agentId, date) => {
|
||||
const dateStr = formatDateForId(date);
|
||||
if (assignments[agentId] && assignments[agentId][dateStr]) {
|
||||
return assignments[agentId][dateStr];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Legacy: Get first assignment as a SHIFTS object (for backward compat) */
|
||||
export const getAssignment = (agentId, date) => {
|
||||
const dateStr = formatDateForId(date);
|
||||
if (assignments[agentId] && assignments[agentId][dateStr] !== undefined) {
|
||||
const data = assignments[agentId][dateStr];
|
||||
// New format: array of shift objects
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return data[0]; // Return first shift for legacy compat
|
||||
}
|
||||
// Old format: shift id string
|
||||
if (typeof data === 'string') {
|
||||
return SHIFTS.find(s => s.id === data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getHoliday = (date) => holidays[formatDateForId(date)] || null;
|
||||
export const getSpecialDay = (date) => specialDays[formatDateForId(date)] || null;
|
||||
|
||||
export const hasComment = (agentId, date) => { const d = formatDateForId(date); return comments[agentId] && comments[agentId][d]; };
|
||||
export const hasNote = (agentId, date) => { const d = formatDateForId(date); return notes[agentId] && notes[agentId][d]; };
|
||||
export const getCommentText = (agentId, date) => { const d = formatDateForId(date); return comments[agentId] ? comments[agentId][d] : ''; };
|
||||
export const getNoteText = (agentId, date) => { const d = formatDateForId(date); return notes[agentId] ? notes[agentId][d] : ''; };
|
||||
|
||||
/* ===== FILTERING ===== */
|
||||
|
||||
export const filteredAgents = computed(() => {
|
||||
return agents.value.filter(a => {
|
||||
const term = (search.value || '').toLowerCase();
|
||||
const matchSearch = term === '' || a.name.toLowerCase().includes(term);
|
||||
const matchDept = activeDept.value === 'All' || a.dept === activeDept.value;
|
||||
const matchHub = activeHub.value === 'All' || a.hub === activeHub.value;
|
||||
const matchRoles = filterRoles.value.length === 0 || filterRoles.value.includes(a.role);
|
||||
const matchSkills = filterSkills.value.length === 0 || a.skills.some(s => filterSkills.value.includes(s));
|
||||
|
||||
let matchAvailability = true;
|
||||
if (filterByAvailability.value && filterDate.value) {
|
||||
const d = new Date(filterDate.value);
|
||||
const shiftsData = getShifts(a.id, d);
|
||||
|
||||
if (!weekendsAreWorkingDays.value && isWeekend(d)) {
|
||||
matchAvailability = false;
|
||||
} else if (!shiftsData || shiftsData.length === 0) {
|
||||
matchAvailability = false;
|
||||
} else {
|
||||
if (filterShiftTypes.value.length > 0) {
|
||||
const hasMatchingShift = shiftsData.some(s =>
|
||||
s.type === 'shift' && filterShiftTypes.value.includes(s.activityId)
|
||||
);
|
||||
if (!hasMatchingShift) matchAvailability = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchSearch && matchDept && matchHub && matchRoles && matchSkills && matchAvailability;
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== GROUPING ===== */
|
||||
|
||||
export const flattenedList = computed(() => {
|
||||
const result = [];
|
||||
const list = [...filteredAgents.value];
|
||||
const keys = ['hub', 'dept'];
|
||||
|
||||
const getLabel = (key, value) => {
|
||||
if (key === 'hub') return HUBS.find(h => h.id === value)?.name || value;
|
||||
if (key === 'dept') return value.toUpperCase() + ' DIVISION';
|
||||
if (key === 'role') return value + 's';
|
||||
return value;
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return list.map(a => ({ type: 'agent', data: a, id: a.id }));
|
||||
}
|
||||
|
||||
list.sort((a, b) => {
|
||||
for (const key of keys) {
|
||||
if (a[key] < b[key]) return -1;
|
||||
if (a[key] > b[key]) return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const currentValues = keys.map(() => null);
|
||||
|
||||
list.forEach(agent => {
|
||||
let changedLevel = -1;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (agent[keys[i]] !== currentValues[i]) { changedLevel = i; break; }
|
||||
}
|
||||
|
||||
if (changedLevel !== -1) {
|
||||
for (let i = changedLevel; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const val = agent[key];
|
||||
currentValues[i] = val;
|
||||
result.push({
|
||||
type: i === 0 ? 'header-l1' : 'header-l2',
|
||||
label: getLabel(key, val),
|
||||
id: `hdr-${key}-${val}-${Math.random()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ type: 'agent', data: agent, id: agent.id });
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/* ===== ASSIGNMENT ACTIONS ===== */
|
||||
|
||||
export const saveAssignment = (selectedAgentRef, selectedDateRef, pendingShiftRef, rightDrawerRef) => {
|
||||
if (!selectedAgentRef.value || !selectedDateRef.value) return;
|
||||
const agentId = selectedAgentRef.value.id;
|
||||
const dateStr = formatDateForId(selectedDateRef.value);
|
||||
if (!assignments[agentId]) assignments[agentId] = {};
|
||||
|
||||
if (pendingShiftRef.value) {
|
||||
// Convert legacy shift to new format
|
||||
const activityType = ACTIVITY_TYPES.find(a => a.label.toUpperCase() === pendingShiftRef.value.label) || ACTIVITY_TYPES[0];
|
||||
assignments[agentId][dateStr] = [{
|
||||
type: 'shift',
|
||||
timeStart: '08:00',
|
||||
timeEnd: '16:00',
|
||||
timeLabel: '08:00 - 16:00',
|
||||
activityId: activityType.id,
|
||||
activityLabel: activityType.label,
|
||||
activityColor: activityType.badgeColor,
|
||||
activityBgColor: activityType.bgColor,
|
||||
activityTextColor: activityType.textColor,
|
||||
}];
|
||||
} else {
|
||||
assignments[agentId][dateStr] = null;
|
||||
}
|
||||
|
||||
pendingShiftRef.value = null;
|
||||
rightDrawerRef.value = false;
|
||||
};
|
||||
204
dev/ui-ux/Opus w images/src/services/planner-state.js
Normal file
204
dev/ui-ux/Opus w images/src/services/planner-state.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* planner-state.js
|
||||
* ================
|
||||
* Central reactive store consumed by every component.
|
||||
* No UI — pure state + derived computations.
|
||||
*/
|
||||
|
||||
const { ref, computed, reactive } = Vue;
|
||||
|
||||
/* ---- helpers ---- */
|
||||
export const formatDateForId = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const month = '' + (d.getMonth() + 1);
|
||||
const day = '' + d.getDate();
|
||||
const year = d.getFullYear();
|
||||
return [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');
|
||||
};
|
||||
|
||||
export const getStartOfWeek = (date) => {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = (day < 1 ? 7 : 0) + day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
export const isWeekend = (date) => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
/* ---- drawer / panel toggles ---- */
|
||||
export const leftDrawer = ref(false);
|
||||
export const filterDrawer = ref(false);
|
||||
export const rightDrawer = ref(false);
|
||||
export const editMode = ref('assignment');
|
||||
export const dateMenu = ref(false);
|
||||
|
||||
/* ---- grid display settings ---- */
|
||||
export const isCompact = ref(false);
|
||||
export const weekendsAreWorkingDays = ref(false);
|
||||
export const viewScope = ref(8);
|
||||
export const pickerStartDay = ref(1);
|
||||
export const showEodTargets = ref(false);
|
||||
export const showAvailability = ref(false);
|
||||
|
||||
/* ---- crosshair / highlighting ---- */
|
||||
export const crosshairActive = ref(true);
|
||||
export const hoveredDateStr = ref(null);
|
||||
export const highlightedRowId = ref(null);
|
||||
export const highlightedDateStr = ref(null);
|
||||
|
||||
export const toggleRowHighlight = (agentId) => {
|
||||
highlightedRowId.value = highlightedRowId.value === agentId ? null : agentId;
|
||||
};
|
||||
export const toggleColHighlight = (date) => {
|
||||
const str = formatDateForId(date);
|
||||
highlightedDateStr.value = highlightedDateStr.value === str ? null : str;
|
||||
};
|
||||
export const clearHighlights = () => {
|
||||
highlightedRowId.value = null;
|
||||
highlightedDateStr.value = null;
|
||||
};
|
||||
|
||||
/* ---- search / filter state ---- */
|
||||
export const search = ref('');
|
||||
export const activeDept = ref('All');
|
||||
export const activeHub = ref('All');
|
||||
export const filterRoles = ref([]);
|
||||
export const filterSkills = ref([]);
|
||||
export const filterByAvailability = ref(false);
|
||||
export const filterDate = ref(formatDateForId(new Date()));
|
||||
export const proxyFilterDate = ref('');
|
||||
export const filterShiftTypes = ref([]);
|
||||
|
||||
/* ---- date navigation ---- */
|
||||
export const startDate = ref(getStartOfWeek(new Date()));
|
||||
export const proxyDate = ref(null);
|
||||
|
||||
/* Month label & nav */
|
||||
export const currentMonthLabel = computed(() => {
|
||||
const d = new Date(startDate.value);
|
||||
// Use middle of the visible range for month label
|
||||
d.setDate(d.getDate() + Math.floor((viewScope.value * 7) / 2));
|
||||
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
});
|
||||
|
||||
export const navigateMonth = (direction) => {
|
||||
const d = new Date(startDate.value);
|
||||
d.setDate(d.getDate() + (direction * 7));
|
||||
startDate.value = getStartOfWeek(d);
|
||||
};
|
||||
|
||||
export const jumpToToday = () => {
|
||||
startDate.value = getStartOfWeek(new Date());
|
||||
};
|
||||
|
||||
/* Today check */
|
||||
export const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
};
|
||||
|
||||
export const dates = computed(() => {
|
||||
const res = [];
|
||||
const now = new Date(startDate.value);
|
||||
for (let i = 0; i < viewScope.value * 7; i++) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + i);
|
||||
res.push(d);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
/* ---- dynamic CSS-variable overrides ---- */
|
||||
export const gridStyles = computed(() => ({
|
||||
'--h-eod': showEodTargets.value ? '42px' : '0px',
|
||||
'--h-status': showAvailability.value ? '34px' : '0px'
|
||||
}));
|
||||
|
||||
/* ---- selected cell / assignment editing ---- */
|
||||
export const selectedAgent = ref(null);
|
||||
export const selectedDate = ref(null);
|
||||
export const pendingShift = ref(null);
|
||||
|
||||
/* ---- filter helpers ---- */
|
||||
export const isFilterActive = computed(() =>
|
||||
filterRoles.value.length > 0 ||
|
||||
filterSkills.value.length > 0 ||
|
||||
activeDept.value !== 'All' ||
|
||||
activeHub.value !== 'All' ||
|
||||
(search.value && search.value.length > 0) ||
|
||||
filterByAvailability.value
|
||||
);
|
||||
|
||||
export const activeFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
if (search.value && search.value.length > 0) count++;
|
||||
if (activeDept.value !== 'All') count++;
|
||||
if (activeHub.value !== 'All') count++;
|
||||
if (filterRoles.value.length > 0) count++;
|
||||
if (filterSkills.value.length > 0) count++;
|
||||
if (filterByAvailability.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
export const clearFilters = () => {
|
||||
search.value = '';
|
||||
activeDept.value = 'All';
|
||||
activeHub.value = 'All';
|
||||
filterRoles.value = [];
|
||||
filterSkills.value = [];
|
||||
filterByAvailability.value = false;
|
||||
filterShiftTypes.value = [];
|
||||
filterDate.value = formatDateForId(new Date());
|
||||
};
|
||||
|
||||
export const applySavedFilter = (key) => {
|
||||
clearFilters();
|
||||
if (key === 'high_potential') {
|
||||
filterSkills.value = ['VIP Concierge', 'Technical Training'];
|
||||
} else if (key === 'remote') {
|
||||
filterRoles.value = ['Specialist'];
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- date picker actions ---- */
|
||||
export const syncProxyDate = () => {
|
||||
proxyDate.value = formatDateForId(selectedDate.value || new Date());
|
||||
};
|
||||
|
||||
export const updateFilterDateProxy = () => {
|
||||
proxyFilterDate.value = filterDate.value;
|
||||
};
|
||||
export const applyFilterDate = () => {
|
||||
filterDate.value = proxyFilterDate.value;
|
||||
};
|
||||
|
||||
/* ---- online users (reactive, driven by socket-service) ---- */
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
export const onlineUsers = ref([]);
|
||||
|
||||
export const visibleOnlineUsers = computed(() =>
|
||||
onlineUsers.value.slice(0, MAX_VISIBLE_AVATARS)
|
||||
);
|
||||
|
||||
export const remainingOnlineCount = computed(() =>
|
||||
Math.max(0, onlineUsers.value.length - MAX_VISIBLE_AVATARS)
|
||||
);
|
||||
|
||||
export const addOnlineUser = (user) => {
|
||||
if (!onlineUsers.value.find(u => u.id === user.id)) {
|
||||
onlineUsers.value = [...onlineUsers.value, user];
|
||||
}
|
||||
};
|
||||
|
||||
export const removeOnlineUser = (userId) => {
|
||||
onlineUsers.value = onlineUsers.value.filter(u => u.id !== userId);
|
||||
};
|
||||
204
dev/ui-ux/Opus w images/src/services/socket-service.js
Normal file
204
dev/ui-ux/Opus w images/src/services/socket-service.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* socket-service.js
|
||||
* ==================
|
||||
* WebSocket simulation for cell-locking and real-time collaboration.
|
||||
* Simulates:
|
||||
* - LOCK_CELL — cell locking events
|
||||
* - USER_ONLINE — a user comes online
|
||||
* - USER_OFFLINE — a user goes offline
|
||||
*
|
||||
* In production, replace the simulation loops with a real
|
||||
* SignalR / native WebSocket connection.
|
||||
*/
|
||||
|
||||
const { ref } = Vue;
|
||||
|
||||
import { formatDateForId, addOnlineUser, removeOnlineUser, onlineUsers } from './planner-state.js';
|
||||
import { agents } from './data-service.js';
|
||||
|
||||
/* ================================================================
|
||||
USER POOL — potential collaborators who may come and go
|
||||
================================================================ */
|
||||
const userPool = [
|
||||
{ id: 'u01', name: 'Anna Schneider', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=anna01' },
|
||||
{ id: 'u02', name: 'Markus Weber', role: 'Planner', img: 'https://i.pravatar.cc/150?u=markus02' },
|
||||
{ id: 'u03', name: 'Lisa Hoffmann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=lisa03' },
|
||||
{ id: 'u04', name: 'Thomas Müller', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=thomas04' },
|
||||
{ id: 'u05', name: 'Julia Fischer', role: 'Planner', img: 'https://i.pravatar.cc/150?u=julia05' },
|
||||
{ id: 'u06', name: 'Stefan Becker', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=stefan06' },
|
||||
{ id: 'u07', name: 'Katharina Wolf', role: 'Planner', img: 'https://i.pravatar.cc/150?u=kath07' },
|
||||
{ id: 'u08', name: 'Daniel Braun', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=daniel08' },
|
||||
{ id: 'u09', name: 'Sandra Koch', role: 'Quality Manager', img: 'https://i.pravatar.cc/150?u=sandra09' },
|
||||
{ id: 'u10', name: 'Michael Richter', role: 'Planner', img: 'https://i.pravatar.cc/150?u=michael10' },
|
||||
{ id: 'u11', name: 'Christina Lang', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=chris11' },
|
||||
{ id: 'u12', name: 'Patrick Schäfer', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=patrick12' },
|
||||
{ id: 'u13', name: 'Nicole Baumann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=nicole13' },
|
||||
{ id: 'u14', name: 'Andreas Klein', role: 'Team Lead', img: 'https://i.pravatar.cc/150?u=andreas14' },
|
||||
{ id: 'u15', name: 'Melanie Kraus', role: 'Planner', img: 'https://i.pravatar.cc/150?u=melanie15' },
|
||||
{ id: 'u16', name: 'Tobias Neumann', role: 'Specialist', img: 'https://i.pravatar.cc/150?u=tobias16' },
|
||||
{ id: 'u17', name: 'Laura Hartmann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=laura17' },
|
||||
{ id: 'u18', name: 'Florian Maier', role: 'Supervisor', img: 'https://i.pravatar.cc/150?u=florian18' },
|
||||
{ id: 'u19', name: 'Sabine Engel', role: 'Quality Manager', img: 'https://i.pravatar.cc/150?u=sabine19' },
|
||||
{ id: 'u20', name: 'Jens Zimmermann', role: 'Planner', img: 'https://i.pravatar.cc/150?u=jens20' },
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
CELL LOCKING (existing)
|
||||
================================================================ */
|
||||
export const lockedCells = ref(new Set()); // "agentId:YYYY-MM-DD"
|
||||
|
||||
/* ================================================================
|
||||
INCOMING MESSAGE HANDLER
|
||||
================================================================ */
|
||||
export const handleWssMessage = (type, payload, $q) => {
|
||||
|
||||
/* ---- cell lock ---- */
|
||||
if (type === 'LOCK_CELL') {
|
||||
const key = `${payload.agentId}:${payload.date}`;
|
||||
lockedCells.value.add(key);
|
||||
|
||||
const agent = agents.value.find(a => a.id === payload.agentId);
|
||||
const name = agent ? agent.name : 'Unknown Agent';
|
||||
|
||||
$q.notify({
|
||||
message: `WSS: Cell Locked for ${name} on ${payload.date}`,
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
icon: 'lock'
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- user comes online ---- */
|
||||
if (type === 'USER_ONLINE') {
|
||||
addOnlineUser(payload);
|
||||
$q.notify({
|
||||
message: `${payload.name} joined`,
|
||||
color: 'white',
|
||||
textColor: 'grey-8',
|
||||
position: 'bottom-right',
|
||||
icon: 'circle',
|
||||
iconColor: 'teal',
|
||||
iconSize: '10px',
|
||||
timeout: 1800,
|
||||
classes: 'text-caption'
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- user goes offline ---- */
|
||||
if (type === 'USER_OFFLINE') {
|
||||
removeOnlineUser(payload.id);
|
||||
$q.notify({
|
||||
message: `${payload.name} left`,
|
||||
color: 'white',
|
||||
textColor: 'grey-6',
|
||||
position: 'bottom-right',
|
||||
icon: 'circle',
|
||||
iconColor: 'grey-4',
|
||||
iconSize: '10px',
|
||||
timeout: 1800,
|
||||
classes: 'text-caption'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
CELL-LOCK SIMULATION (existing)
|
||||
================================================================ */
|
||||
export const simulateWssLock = ($q) => {
|
||||
if (agents.value.length === 0) return;
|
||||
|
||||
const randomAgent = agents.value[Math.floor(Math.random() * agents.value.length)];
|
||||
|
||||
let targetDate = new Date();
|
||||
const day = targetDate.getDay();
|
||||
if (day === 0) targetDate.setDate(targetDate.getDate() + 1);
|
||||
else if (day === 6) targetDate.setDate(targetDate.getDate() + 2);
|
||||
|
||||
const dateStr = formatDateForId(targetDate);
|
||||
|
||||
handleWssMessage('LOCK_CELL', { agentId: randomAgent.id, date: dateStr }, $q);
|
||||
};
|
||||
|
||||
/* ---- cell-lock check ---- */
|
||||
export const isCellLocked = (agentId, date) => {
|
||||
const key = `${agentId}:${formatDateForId(date)}`;
|
||||
return lockedCells.value.has(key);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
ONLINE-USERS SIMULATION
|
||||
Continuously adds/removes users at random intervals (3–10 s).
|
||||
Starts with an initial batch of 3–5 users so the UI isn't empty.
|
||||
================================================================ */
|
||||
let onlineSimTimer = null;
|
||||
|
||||
/** Random int between min and max (inclusive). */
|
||||
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
/** Get users from the pool that are NOT currently online. */
|
||||
const offlinePoolUsers = () =>
|
||||
userPool.filter(u => !onlineUsers.value.find(o => o.id === u.id));
|
||||
|
||||
/** Pick a random element from an array. */
|
||||
const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
/** Schedule the next online-users event after a random delay. */
|
||||
const scheduleNext = ($q) => {
|
||||
const delay = randInt(3000, 10000);
|
||||
onlineSimTimer = setTimeout(() => simulateOnlineEvent($q), delay);
|
||||
};
|
||||
|
||||
/** One simulation tick: either bring someone online or take someone offline. */
|
||||
const simulateOnlineEvent = ($q) => {
|
||||
const currentCount = onlineUsers.value.length;
|
||||
const offline = offlinePoolUsers();
|
||||
|
||||
// Decide whether to add or remove.
|
||||
// Bias towards adding when few users are online, towards removing when many.
|
||||
let shouldAdd;
|
||||
if (currentCount === 0) shouldAdd = true;
|
||||
else if (offline.length === 0) shouldAdd = false;
|
||||
else if (currentCount <= 2) shouldAdd = Math.random() < 0.85;
|
||||
else if (currentCount >= userPool.length - 2) shouldAdd = Math.random() < 0.15;
|
||||
else shouldAdd = Math.random() < 0.55;
|
||||
|
||||
if (shouldAdd && offline.length > 0) {
|
||||
const user = pickRandom(offline);
|
||||
handleWssMessage('USER_ONLINE', user, $q);
|
||||
} else if (currentCount > 0) {
|
||||
const user = pickRandom(onlineUsers.value);
|
||||
handleWssMessage('USER_OFFLINE', user, $q);
|
||||
}
|
||||
|
||||
// Schedule next event
|
||||
scheduleNext($q);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the online-users simulation.
|
||||
* Seeds 3–5 users immediately, then continues at random intervals.
|
||||
*/
|
||||
export const startOnlineUsersSimulation = ($q) => {
|
||||
// Stop any existing simulation
|
||||
stopOnlineUsersSimulation();
|
||||
|
||||
// Seed initial users (3–5) without notifications
|
||||
const shuffled = [...userPool].sort(() => Math.random() - 0.5);
|
||||
const seedCount = randInt(3, 5);
|
||||
for (let i = 0; i < seedCount; i++) {
|
||||
addOnlineUser(shuffled[i]);
|
||||
}
|
||||
|
||||
// Start the continuous loop
|
||||
scheduleNext($q);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the online-users simulation and clear all online users.
|
||||
*/
|
||||
export const stopOnlineUsersSimulation = () => {
|
||||
if (onlineSimTimer) {
|
||||
clearTimeout(onlineSimTimer);
|
||||
onlineSimTimer = null;
|
||||
}
|
||||
};
|
||||
158
dev/ui-ux/Opus w images/src/styles/global.css
Normal file
158
dev/ui-ux/Opus w images/src/styles/global.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* =============================================
|
||||
GLOBAL STYLES — shared variables, resets, scrollbars
|
||||
============================================= */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
:root {
|
||||
--left-col-width: 260px;
|
||||
--cell-width: 155px;
|
||||
--h-eod: 42px;
|
||||
--h-status: 34px;
|
||||
--h-dates: 62px;
|
||||
--primary-soft: #f8fafc;
|
||||
--border-color: #e8ecf1;
|
||||
--highlight-bg: rgba(59, 130, 246, 0.08);
|
||||
--highlight-border: rgba(59, 130, 246, 0.35);
|
||||
--brand-blue: #2563eb;
|
||||
--brand-blue-light: #dbeafe;
|
||||
--brand-indigo: #4f46e5;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--bg-page: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
|
||||
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* MOBILE OPTIMIZATION */
|
||||
@media (max-width: 599px) {
|
||||
:root {
|
||||
--left-col-width: 170px;
|
||||
--cell-width: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
body, html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Quasar overrides */
|
||||
.q-field--outlined.q-field--rounded .q-field__control { border-radius: var(--radius-md); }
|
||||
.q-btn--rounded { border-radius: var(--radius-md); }
|
||||
.q-card { border-radius: var(--radius-lg) !important; }
|
||||
|
||||
/* Override Quasar fonts */
|
||||
.q-toolbar-title, .q-item__label, .q-field__native, .q-btn__content {
|
||||
font-family: 'Inter', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 20px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Badge styles for skills/roles */
|
||||
.skill-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-badge--blue {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.skill-badge--green {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.skill-badge--red {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.skill-badge--orange {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
.skill-badge--purple {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.skill-badge--pink {
|
||||
background: #fce7f3;
|
||||
color: #db2777;
|
||||
}
|
||||
.skill-badge--teal {
|
||||
background: #ccfbf1;
|
||||
color: #0d9488;
|
||||
}
|
||||
.skill-badge--gray {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.skill-badge--indigo {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
z-index: 2;
|
||||
}
|
||||
.status-dot--online { background: #22c55e; }
|
||||
.status-dot--away { background: #f59e0b; }
|
||||
.status-dot--busy { background: #ef4444; }
|
||||
.status-dot--offline { background: #d1d5db; }
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
15
dev/ui-ux/Opus w images/start.command
Executable file
15
dev/ui-ux/Opus w images/start.command
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
PORT=5186
|
||||
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