Added new modules and updated existing logic
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/* app-header — top toolbar (avatar stack styles moved to online-users component) */
|
||||
59
dev/ui-ux/Opus 4.6/src/components/app-header/app-header.js
Normal file
59
dev/ui-ux/Opus 4.6/src/components/app-header/app-header.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* app-header.js
|
||||
* ==============
|
||||
* Top toolbar: logo, online-users component, notifications,
|
||||
* clear-highlights button.
|
||||
*/
|
||||
|
||||
import {
|
||||
leftDrawer,
|
||||
highlightedRowId,
|
||||
highlightedDateStr,
|
||||
clearHighlights
|
||||
} 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
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-header class="bg-white text-grey-9 border-b" style="border-bottom: 1px solid #e2e8f0">
|
||||
<q-toolbar class="q-py-sm">
|
||||
<q-btn flat round dense icon="menu" color="grey-7" @click="leftDrawer = !leftDrawer"></q-btn>
|
||||
<div class="row items-center q-ml-md">
|
||||
<q-toolbar-title class="text-weight-bold text-subtitle1 text-grey-9 ellipsis" style="max-width: 200px;">Hotline Planner</q-toolbar-title>
|
||||
</div>
|
||||
<q-space></q-space>
|
||||
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<!-- Clear highlights button -->
|
||||
<transition appear enter-active-class="animated fadeIn" leave-active-class="animated fadeOut">
|
||||
<q-btn v-if="highlightedRowId || highlightedDateStr"
|
||||
outline rounded dense
|
||||
color="amber-9"
|
||||
icon="highlight_off"
|
||||
:label="$q.screen.gt.xs ? 'Clear Highlights' : ''"
|
||||
class="q-mr-xs bg-amber-1"
|
||||
@click="clearHighlights">
|
||||
<q-tooltip v-if="!$q.screen.gt.xs">Clear Highlights</q-tooltip>
|
||||
</q-btn>
|
||||
</transition>
|
||||
|
||||
<!-- Online users (reactive, WSS-driven) -->
|
||||
<online-users></online-users>
|
||||
|
||||
<q-btn flat dense icon="notifications" color="grey-6" class="gt-xs q-ml-sm" label="Alerts"></q-btn>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/* assignment-drawer — right overlay for editing shifts */
|
||||
|
||||
.assignment-drawer-root {
|
||||
/* intentionally empty — styled via Quasar props */
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* assignment-drawer.js
|
||||
* =====================
|
||||
* Right overlay drawer: agent profile, shift assignment buttons,
|
||||
* save / cancel actions.
|
||||
*/
|
||||
|
||||
import {
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedAgent,
|
||||
selectedDate,
|
||||
pendingShift,
|
||||
formatDateForId
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
SHIFTS,
|
||||
getAssignment,
|
||||
assignments,
|
||||
saveAssignment as doSave
|
||||
} from '../../services/data-service.js';
|
||||
|
||||
export default {
|
||||
name: 'AssignmentDrawer',
|
||||
setup() {
|
||||
const currentAssignmentLabel = Vue.computed(() => {
|
||||
if (pendingShift.value) return pendingShift.value.label;
|
||||
if (!selectedAgent.value || !selectedDate.value) return null;
|
||||
const a = getAssignment(selectedAgent.value.id, selectedDate.value);
|
||||
return a ? a.label : null;
|
||||
});
|
||||
|
||||
const setPendingShift = (shift) => { pendingShift.value = shift; };
|
||||
|
||||
const saveAssignment = () => {
|
||||
doSave(selectedAgent, selectedDate, pendingShift, rightDrawer);
|
||||
};
|
||||
|
||||
return {
|
||||
rightDrawer,
|
||||
editMode,
|
||||
selectedAgent,
|
||||
selectedDate,
|
||||
pendingShift,
|
||||
formatDateForId,
|
||||
shifts: SHIFTS,
|
||||
currentAssignmentLabel,
|
||||
setPendingShift,
|
||||
saveAssignment
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer v-model="rightDrawer" side="right" bordered :width="380" overlay>
|
||||
<div class="column full-height bg-white">
|
||||
<q-toolbar class="bg-white border-b q-px-md" style="height: 64px">
|
||||
<q-toolbar-title class="text-subtitle1 text-weight-bold">
|
||||
{{ editMode === 'assignment' ? 'Edit Assignment' : 'Agent Details' }}
|
||||
</q-toolbar-title>
|
||||
<q-btn flat round dense icon="close" color="grey-5" @click="rightDrawer = false"></q-btn>
|
||||
</q-toolbar>
|
||||
|
||||
<q-scroll-area class="col q-pa-lg">
|
||||
<div v-if="selectedAgent" class="row items-center q-mb-xl">
|
||||
<q-avatar size="64px"><img :src="selectedAgent.avatar"></q-avatar>
|
||||
<div class="q-ml-md">
|
||||
<div class="text-h6 text-weight-bold">{{ selectedAgent.name }}</div>
|
||||
<div class="text-caption text-grey-7">{{ selectedAgent.dept }} • {{ selectedAgent.role }}</div>
|
||||
<div class="text-caption text-grey-7 text-weight-bold q-mt-xs">
|
||||
{{ selectedAgent.hubName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="editMode === 'assignment' && selectedDate">
|
||||
<div class="q-mb-lg">
|
||||
<div class="text-overline text-grey-5">Shift Date</div>
|
||||
<div class="text-subtitle1 text-weight-bold text-grey-9">
|
||||
{{ selectedDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-weight-bold q-mb-md">Standard Shifts</div>
|
||||
<div class="row q-col-gutter-sm q-mb-xl">
|
||||
<div v-for="s in shifts" :key="s.id" class="col-6">
|
||||
<q-btn :color="s.color"
|
||||
:unelevated="currentAssignmentLabel === s.label"
|
||||
:outline="currentAssignmentLabel !== s.label"
|
||||
rounded dense no-caps :label="s.label"
|
||||
class="full-width q-py-sm"
|
||||
@click="setPendingShift(s)"></q-btn>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-btn outline rounded dense color="red-4" label="Clear Assignment"
|
||||
class="full-width q-mt-sm" icon="delete_outline"
|
||||
@click="setPendingShift(null)"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-overline text-grey-5 q-mb-xs">Cell Identifier</div>
|
||||
<div class="text-caption text-grey-5 font-mono break-all">
|
||||
#cell-{{selectedAgent.id}}-{{formatDateForId(selectedDate)}}
|
||||
</div>
|
||||
</template>
|
||||
</q-scroll-area>
|
||||
|
||||
<div class="q-pa-lg border-t row q-gutter-md bg-grey-1">
|
||||
<q-btn flat rounded label="Cancel" class="col" @click="rightDrawer = false"></q-btn>
|
||||
<q-btn unelevated rounded color="grey-9" label="Save Assignment" class="col" @click="saveAssignment"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.date-header-bg-weekend { background-color: #f1f5f9 !important; }
|
||||
.date-header-text-sat { color: #166534 !important; }
|
||||
.date-header-text-sun { color: #991b1b !important; }
|
||||
|
||||
.date-header-reading-active {
|
||||
background-color: #fef9c3 !important;
|
||||
border-bottom: 2px solid #94a3b8;
|
||||
}
|
||||
|
||||
.date-header-col-hovered {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-header-hover-trigger .date-header-highlight-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.date-header-hover-trigger:hover .date-header-highlight-btn,
|
||||
.date-header-highlight-btn.date-header-is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.date-header-active-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #64748b;
|
||||
}
|
||||
101
dev/ui-ux/Opus 4.6/src/components/date-header/date-header.js
Normal file
101
dev/ui-ux/Opus 4.6/src/components/date-header/date-header.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* date-header.js
|
||||
* ===============
|
||||
* A single date-column header cell.
|
||||
* Shows weekday, day number, holiday / event icons, highlight toggle.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatDateForId,
|
||||
isWeekend,
|
||||
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 rootClass = Vue.computed(() => {
|
||||
const cls = ['date-header-root', 'date-header-hover-trigger'];
|
||||
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');
|
||||
return cls.join(' ');
|
||||
});
|
||||
|
||||
const isWkndNonWorking = Vue.computed(() => isWeekend(props.date) && !weekendsAreWorkingDays.value);
|
||||
const isSat = Vue.computed(() => props.date.getDay() === 6);
|
||||
|
||||
const dayLabelClass = Vue.computed(() => {
|
||||
if (isWkndNonWorking.value) return isSat.value ? 'date-header-text-sat' : 'date-header-text-sun';
|
||||
return 'text-grey-5';
|
||||
});
|
||||
const dayNumClass = Vue.computed(() => {
|
||||
if (isWkndNonWorking.value) return isSat.value ? 'date-header-text-sat' : 'date-header-text-sun';
|
||||
return 'text-grey-9';
|
||||
});
|
||||
|
||||
const weekdayShort = Vue.computed(() =>
|
||||
props.date.toLocaleDateString('en-US', { weekday: 'short' })
|
||||
);
|
||||
const dayNum = Vue.computed(() => props.date.getDate());
|
||||
const monthShort = Vue.computed(() =>
|
||||
props.date.toLocaleDateString('en-US', { month: 'short' })
|
||||
);
|
||||
|
||||
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, dayLabelClass, dayNumClass,
|
||||
weekdayShort, dayNum, monthShort,
|
||||
holiday, specialDay, isHighlighted, dateStr,
|
||||
onEnter, onLeave, onToggle
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div :class="rootClass"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave">
|
||||
|
||||
<div class="absolute-top-right q-pa-xs">
|
||||
<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>
|
||||
|
||||
<div class="text-[10px] uppercase text-weight-bold" :class="dayLabelClass">{{ weekdayShort }}</div>
|
||||
<div class="text-subtitle2 text-weight-bold leading-none" :class="dayNumClass">{{ dayNum }}. {{ monthShort }}</div>
|
||||
|
||||
<q-btn round flat dense
|
||||
:icon="isHighlighted ? 'visibility_off' : 'visibility'"
|
||||
:color="isHighlighted ? 'amber-9' : 'grey-5'"
|
||||
size="sm"
|
||||
class="date-header-highlight-btn absolute-bottom-right q-ma-xs"
|
||||
:class="{ 'date-header-is-active': isHighlighted }"
|
||||
@click.stop="onToggle">
|
||||
<q-tooltip>{{ isHighlighted ? 'Turn off column mode' : 'Highlight Column' }}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<div v-if="isHighlighted" class="date-header-active-bar"></div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/* filter-drawer — right side filter panel */
|
||||
|
||||
.filter-drawer-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
color: #94a3b8;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
128
dev/ui-ux/Opus 4.6/src/components/filter-drawer/filter-drawer.js
Normal file
128
dev/ui-ux/Opus 4.6/src/components/filter-drawer/filter-drawer.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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,
|
||||
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,
|
||||
depts: DEPARTMENTS,
|
||||
roles: ROLES,
|
||||
allSkills: SKILLS,
|
||||
hubOptions
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-drawer v-model="filterDrawer" side="right" bordered :width="300" class="bg-grey-1">
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="row items-center justify-between q-mb-xs">
|
||||
<div class="text-h6 text-weight-bold text-grey-9">Filters</div>
|
||||
<q-btn flat round dense icon="close" size="sm" color="grey-5" @click="filterDrawer = false"></q-btn>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mb-sm">Refine the agent list by skills and expertise.</div>
|
||||
|
||||
<q-btn flat rounded 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" color="grey-6"></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="grey-6"></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="grey-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-mb-md">
|
||||
<q-toggle v-model="filterByAvailability" label="Filter by Active Shift" dense class="q-mb-sm text-weight-medium text-caption" color="grey-8"></q-toggle>
|
||||
<div v-if="filterByAvailability" class="q-gutter-y-sm q-mt-xs animated fadeIn">
|
||||
<q-input v-model="filterDate" filled dense label="Target Date" class="text-caption">
|
||||
<template v-slot:append>
|
||||
<q-icon name="event" class="cursor-pointer">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale" @before-show="updateFilterDateProxy">
|
||||
<div class="column bg-white">
|
||||
<q-date v-model="proxyFilterDate" mask="YYYY-MM-DD" minimal color="indigo-8" :first-day-of-week="pickerStartDay">
|
||||
<div class="row items-center justify-end q-pa-sm border-t q-gutter-sm">
|
||||
<q-btn v-close-popup label="Cancel" color="grey-7" flat dense></q-btn>
|
||||
<q-btn v-close-popup label="OK" color="indigo-8" flat dense @click="applyFilterDate"></q-btn>
|
||||
</div>
|
||||
</q-date>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Required Shift Type:</div>
|
||||
<div class="row q-gutter-xs">
|
||||
<q-checkbox v-for="s in shifts" :key="s.id" 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>
|
||||
`
|
||||
};
|
||||
66
dev/ui-ux/Opus 4.6/src/components/grid-cell/grid-cell.css
Normal file
66
dev/ui-ux/Opus 4.6/src/components/grid-cell/grid-cell.css
Normal file
@@ -0,0 +1,66 @@
|
||||
/* grid-cell — individual shift cell */
|
||||
|
||||
.grid-cell-root {
|
||||
width: var(--cell-width);
|
||||
min-width: var(--cell-width);
|
||||
height: 58px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.grid-cell-root:hover:not(.cursor-not-allowed) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.grid-cell-compact {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.grid-cell-shift-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.grid-cell-tooltip {
|
||||
padding: 10px 12px;
|
||||
max-width: 220px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Weekend + highlight helpers (applied via dynamic class) */
|
||||
.grid-cell-bg-weekend { background-color: #f1f5f9 !important; }
|
||||
|
||||
.grid-cell-bg-reading-mode {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
.grid-cell-bg-reading-mode-intersection {
|
||||
background-color: #fff176 !important;
|
||||
}
|
||||
|
||||
.grid-cell-col-hovered {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
z-index: 1;
|
||||
}
|
||||
112
dev/ui-ux/Opus 4.6/src/components/grid-cell/grid-cell.js
Normal file
112
dev/ui-ux/Opus 4.6/src/components/grid-cell/grid-cell.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* grid-cell.js
|
||||
* =============
|
||||
* A single shift cell inside an agent row.
|
||||
* Props: agentId, date
|
||||
*/
|
||||
|
||||
import {
|
||||
formatDateForId,
|
||||
isWeekend,
|
||||
weekendsAreWorkingDays,
|
||||
isCompact,
|
||||
crosshairActive,
|
||||
hoveredDateStr,
|
||||
highlightedRowId,
|
||||
highlightedDateStr
|
||||
} from '../../services/planner-state.js';
|
||||
|
||||
import {
|
||||
getAssignment,
|
||||
hasComment,
|
||||
hasNote,
|
||||
getCommentText,
|
||||
getNoteText
|
||||
} 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 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 (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 assignment = Vue.computed(() => getAssignment(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));
|
||||
|
||||
const onEnter = () => { hoveredDateStr.value = formatDateForId(props.date); };
|
||||
const onLeave = () => { hoveredDateStr.value = null; };
|
||||
const onClick = () => { emit('open-assignment', props.date); };
|
||||
|
||||
return {
|
||||
cellClass, assignment, locked, showShift,
|
||||
comment, note, commentTxt, noteTxt,
|
||||
onEnter, onLeave, onClick
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div :class="cellClass"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick">
|
||||
<template v-if="showShift">
|
||||
<div v-if="assignment"
|
||||
class="grid-cell-shift-badge"
|
||||
:class="assignment.badgeClass">{{ assignment.label }}</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="absolute-bottom-right q-pa-xs row no-wrap" style="gap: 2px">
|
||||
<q-icon v-if="comment" name="chat_bubble" size="8px" color="blue-grey-3" class="cursor-help opacity-60">
|
||||
<q-tooltip class="bg-white text-grey-9 border grid-cell-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-grey-8 q-mb-xs">User Comment</div>
|
||||
<div class="text-caption text-grey-7">{{ commentTxt }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-if="note" name="info" size="8px" color="orange-4" class="cursor-help opacity-70">
|
||||
<q-tooltip class="bg-white text-grey-9 border grid-cell-tooltip" anchor="top middle" self="bottom middle">
|
||||
<div class="text-weight-bold text-caption text-grey-8 q-mb-xs">Technical Note</div>
|
||||
<div class="text-caption text-grey-7">{{ noteTxt }}</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/* 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: border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.online-users-avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.online-users-avatar:hover {
|
||||
z-index: 10;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* enter / leave transitions — subtle opacity only */
|
||||
.online-avatar-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.online-avatar-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.online-avatar-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.online-avatar-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* +N overflow circle */
|
||||
.online-users-overflow {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: -8px;
|
||||
border-radius: 50%;
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
z-index: 5;
|
||||
border: 2px solid white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.online-users-overflow:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 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: 240px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* online indicator dot */
|
||||
.online-users-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
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; }
|
||||
}
|
||||
129
dev/ui-ux/Opus 4.6/src/components/online-users/online-users.js
Normal file
129
dev/ui-ux/Opus 4.6/src/components/online-users/online-users.js
Normal file
@@ -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 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">
|
||||
<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" 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" 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>
|
||||
`
|
||||
};
|
||||
209
dev/ui-ux/Opus 4.6/src/components/planner-grid/planner-grid.css
Normal file
209
dev/ui-ux/Opus 4.6/src/components/planner-grid/planner-grid.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/* planner-grid — main scrollable grid area */
|
||||
|
||||
.planner-grid-viewport {
|
||||
height: calc(100vh - 64px) !important;
|
||||
overflow: auto !important;
|
||||
background: white;
|
||||
position: relative;
|
||||
display: block;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.planner-grid-viewport::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 20px;
|
||||
border: 2px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.planner-grid-content {
|
||||
display: inline-block;
|
||||
min-width: fit-content;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.planner-grid-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.planner-grid-left-col {
|
||||
width: var(--left-col-width);
|
||||
min-width: var(--left-col-width);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.planner-grid-left-col {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.planner-grid-agent-role {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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: #fafafa;
|
||||
}
|
||||
.planner-grid-row-eod .planner-grid-left-col {
|
||||
z-index: 60;
|
||||
background: #fafafa;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.planner-grid-row-dates .planner-grid-left-col {
|
||||
z-index: 58;
|
||||
background: #fdfdfd;
|
||||
padding-right: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Group headers — flat, minimal enclosure */
|
||||
.planner-grid-header-l1 {
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
color: #1e293b;
|
||||
letter-spacing: 0.05em;
|
||||
height: 36px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.planner-grid-header-l1 .planner-grid-left-col {
|
||||
background: white;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.planner-grid-header-l2 {
|
||||
background: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.025em;
|
||||
height: 32px;
|
||||
border-top: none;
|
||||
}
|
||||
.planner-grid-header-l2 .planner-grid-left-col {
|
||||
background: white;
|
||||
color: #64748b;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* Agent row */
|
||||
.planner-grid-compact {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
/* Row highlight (persistent reading helper) */
|
||||
.planner-grid-row-highlighted .planner-grid-left-col {
|
||||
background-color: #fff9c4 !important;
|
||||
}
|
||||
|
||||
/* Agent row hover triggers for highlight button */
|
||||
.planner-grid-agent-hover .planner-grid-highlight-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.planner-grid-agent-hover: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);
|
||||
}
|
||||
.planner-grid-row-item.planner-grid-crosshair-enabled:hover .planner-grid-left-col {
|
||||
background-color: #eef2ff !important;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
|
||||
/* Filter button */
|
||||
.planner-grid-filter-btn-active {
|
||||
background-color: #f1f5f9 !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
.planner-grid-filter-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
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;
|
||||
}
|
||||
290
dev/ui-ux/Opus 4.6/src/components/planner-grid/planner-grid.js
Normal file
290
dev/ui-ux/Opus 4.6/src/components/planner-grid/planner-grid.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* planner-grid.js
|
||||
* =================
|
||||
* Main grid area: EOD targets row, availability row, date header row,
|
||||
* loading skeleton, QVirtualScroll with group headers + agent rows.
|
||||
* Uses child components: DateHeader, GridCell.
|
||||
*/
|
||||
|
||||
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
|
||||
} 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,
|
||||
applyDateSelection,
|
||||
openAssignment,
|
||||
openProfile
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<q-page class="q-pa-none">
|
||||
<!-- LOADING OVERLAY -->
|
||||
<q-inner-loading :showing="loading">
|
||||
<q-spinner size="32px" color="grey-7"></q-spinner>
|
||||
<div class="text-grey-7 q-mt-sm text-caption">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 text-[10px] text-pink-7">94%</div>
|
||||
<q-linear-progress :value="0.94" color="pink-3" size="3px" rounded style="width: 60%"></q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Availability Row -->
|
||||
<div v-if="showAvailability" class="planner-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">
|
||||
<div class="planner-grid-search-container">
|
||||
<q-input v-model="search" debounce="300" dense outlined rounded
|
||||
:placeholder="$q.screen.lt.sm ? 'Search' : 'Quick search...'"
|
||||
class="full-width" bg-color="white" clearable>
|
||||
<template v-slot:prepend><q-icon name="search" size="xs" color="grey-5"></q-icon></template>
|
||||
</q-input>
|
||||
<q-btn flat dense icon="filter_alt"
|
||||
:color="isFilterActive ? 'grey-9' : 'grey-6'"
|
||||
:class="isFilterActive ? 'planner-grid-filter-btn-active' : ''"
|
||||
size="sm"
|
||||
:label="$q.screen.gt.sm ? 'Filters' : ''"
|
||||
@click="filterDrawer = !filterDrawer">
|
||||
<div v-if="isFilterActive" class="planner-grid-filter-status-dot"></div>
|
||||
<q-tooltip v-if="!$q.screen.gt.sm">{{ isFilterActive ? activeFilterCount + ' filters active' : 'Filters' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense icon="calendar_today" color="grey-6" size="sm" :label="$q.screen.gt.sm ? 'Date' : ''">
|
||||
<q-menu v-model="dateMenu" @show="syncProxyDate">
|
||||
<q-card class="planner-grid-dense-date-card border">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-date v-model="proxyDate" minimal flat color="indigo-8" :first-day-of-week="pickerStartDay"></q-date>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="q-pt-none q-pb-sm q-px-md">
|
||||
<q-btn flat dense rounded label="Cancel" color="grey-7" @click="dateMenu = false"></q-btn>
|
||||
<q-btn unelevated dense rounded label="OK" color="indigo-8" class="q-px-md" @click="applyDateSelection"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="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 18" :key="n" class="planner-grid-row">
|
||||
<div class="planner-grid-left-col border-b" style="height: 59px">
|
||||
<div class="row items-center full-width">
|
||||
<q-skeleton type="QAvatar" size="32px" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<q-skeleton type="text" width="60%" />
|
||||
<q-skeleton type="text" width="40%" height="10px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planner-grid-cells-area">
|
||||
<div v-for="c in 8*7" :key="c" class="planner-grid-eod-cell" style="width:var(--cell-width);min-width:var(--cell-width);height:58px">
|
||||
<!--<q-skeleton v-if="Math.random() > 0.6" type="rect" height="20px" width="80%"
|
||||
class="rounded-borders" style="opacity: 0.3" />-->
|
||||
<q-skeleton type="rect" height="20px" width="70%"
|
||||
class="rounded-borders" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIRTUAL SCROLL -->
|
||||
<q-virtual-scroll
|
||||
v-else
|
||||
scroll-target="#viewport-target"
|
||||
:items="flattenedList"
|
||||
:item-size="isCompact ? 43 : 59"
|
||||
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
|
||||
}">
|
||||
<div class="planner-grid-left-col border-b cursor-pointer planner-grid-agent-hover relative-position"
|
||||
:class="[isCompact ? 'planner-grid-compact' : '']"
|
||||
@click="openProfile(item.data)">
|
||||
<q-avatar :size="isCompact ? '24px' : '32px'">
|
||||
<img :src="item.data.avatar">
|
||||
</q-avatar>
|
||||
<div class="q-ml-sm overflow-hidden col">
|
||||
<div class="text-weight-bold truncate" :style="{fontSize: isCompact ? '11px' : '13px'}">{{ item.data.name }}</div>
|
||||
<div v-if="!isCompact"
|
||||
class="text-[10px] text-grey-5 uppercase text-weight-bold truncate planner-grid-agent-role">{{ item.data.role }}</div>
|
||||
</div>
|
||||
<q-btn round flat dense
|
||||
:icon="highlightedRowId === item.data.id ? 'visibility_off' : 'visibility'"
|
||||
:color="highlightedRowId === item.data.id ? 'amber-9' : 'grey-4'"
|
||||
size="sm"
|
||||
class="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>
|
||||
<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,7 @@
|
||||
/* workspace-drawer — left settings panel */
|
||||
/* All styling uses Quasar utility classes in the template.
|
||||
This file is a placeholder for future custom styles. */
|
||||
|
||||
.workspace-drawer-root {
|
||||
/* intentionally empty — styled via Quasar props */
|
||||
}
|
||||
@@ -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="bg-white">
|
||||
<q-scroll-area class="fit">
|
||||
<div class="q-pa-lg">
|
||||
<div class="text-h6 text-weight-bold q-mb-xs text-grey-9">Workspace</div>
|
||||
<div class="text-caption text-grey-6 q-mb-xl">Manage grid and picker preferences.</div>
|
||||
|
||||
<div class="column q-gutter-y-lg">
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Timeline Range</div>
|
||||
<q-select 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="text-overline text-grey-6 q-mb-sm">Date Picker Week Start</div>
|
||||
<q-select v-model="pickerStartDay" :options="[{label: 'Sunday', value: 0}, {label: 'Monday', value: 1}]" outlined dense rounded emit-value map-options bg-color="white"></q-select>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<!-- Dev Tools -->
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Dev Tools</div>
|
||||
<q-btn outline rounded color="grey-7" label="Simulate WSS Lock" class="full-width" icon="lock" @click="doSimulate">
|
||||
<q-tooltip>Simulate receiving a "Lock Cell" message from server</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<div>
|
||||
<div class="text-overline text-grey-6 q-mb-sm">Grid Visibility</div>
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Reading Crosshair</q-item-label><q-item-label caption>Dynamic highlight on hover</q-item-label></q-item-section>
|
||||
<q-item-section side><q-toggle v-model="crosshairActive" color="grey-8"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Show EOD Targets</q-item-label><q-item-label caption>Top row KPI</q-item-label></q-item-section>
|
||||
<q-item-section side><q-toggle v-model="showEodTargets" color="grey-8"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Show 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="grey-8"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Compact Grid</q-item-label><q-item-label caption>High density view</q-item-label></q-item-section>
|
||||
<q-item-section side><q-toggle v-model="isCompact" color="grey-8"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple class="q-px-none">
|
||||
<q-item-section><q-item-label class="text-weight-medium">Working Weekends</q-item-label><q-item-label caption>Enable Sat/Sun shifts</q-item-label></q-item-section>
|
||||
<q-item-section side><q-toggle v-model="weekendsAreWorkingDays" color="grey-8"></q-toggle></q-item-section>
|
||||
</q-item>
|
||||
<q-btn outline rounded color="grey-8" label="Reset to Today" icon="today" class="full-width q-mt-md" @click="resetToToday"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
`
|
||||
};
|
||||
Reference in New Issue
Block a user