Files
hotline-planner/monolith/hlp quasar initial layout split/copy paste feature
2026-02-24 13:32:01 +01:00

531 lines
24 KiB
Plaintext

<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.prod.css" rel="stylesheet" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--left-col-width: 240px;
--cell-width: 100px;
--h-dates: 52px;
--border-color: #e2e8f0;
--highlight-bg: rgba(99, 102, 241, 0.08);
--highlight-border: rgba(99, 102, 241, 0.3);
--selection-color: #3b82f6;
}
body, html {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: #f8fafc;
color: #334155;
user-select: none;
-webkit-user-select: none;
}
body.is-selecting-active * {
user-select: none !important;
-webkit-user-select: none !important;
}
.grid-viewport {
height: calc(100vh - 64px) !important;
overflow: auto !important;
background: white;
position: relative;
display: block;
border-top: 1px solid var(--border-color);
}
.planner-content { display: inline-block; min-width: fit-content; vertical-align: top; }
.planner-row { display: flex; flex-direction: row; border-bottom: 1px solid #f1f5f9; }
.left-col {
width: var(--left-col-width);
min-width: var(--left-col-width);
position: sticky;
left: 0;
z-index: 20;
background: white;
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
}
.row-dates { position: sticky; top: 0; z-index: 38; height: var(--h-dates); background: #fdfdfd; }
.row-dates .left-col { z-index: 58; background: #fdfdfd; border-bottom: 1px solid var(--border-color); }
.cells-area { display: flex; flex-direction: row; }
.cell {
width: var(--cell-width);
min-width: var(--cell-width);
height: 58px;
border-right: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.05s ease;
cursor: cell;
}
.cell:hover:not(.cursor-not-allowed) { background-color: #f8fafc; }
.cell-compact { height: 42px; }
.cell-selected {
background-color: rgba(59, 130, 246, 0.12) !important;
box-shadow: inset 0 0 0 2px var(--selection-color) !important;
z-index: 10;
}
.shift-badge {
font-size: 9px;
font-weight: 800;
padding: 2px 4px;
border-radius: 4px;
width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
border: 1px solid rgba(0,0,0,0.05);
pointer-events: none;
user-select: none;
}
.group-header-l1 { background: #eef2ff; font-size: 12px; font-weight: 900; color: #3730a3; height: 36px; border-top: 1px solid #c7d2fe; }
.bg-weekend { background-color: #f1f5f9 !important; }
.col-hovered { background-color: var(--highlight-bg) !important; }
.grid-viewport::-webkit-scrollbar { width: 8px; height: 8px; }
.grid-viewport::-webkit-scrollbar-track { background: #f1f5f9; }
.grid-viewport::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
</style>
</head>
<body @mouseup="onGlobalMouseUp" :class="{'is-selecting-active': isSelecting}">
<div id="q-app">
<q-layout view="hHh Lpr fFf" @keydown.esc="clearSelection">
<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">
<div class="bg-indigo-1 q-pa-xs rounded-borders q-mr-sm"><q-icon name="edit_calendar" color="indigo-8" size="24px"></q-icon></div>
<q-toolbar-title class="text-weight-bold text-subtitle1 text-indigo-10">Hotline Planner Pro</q-toolbar-title>
</div>
<q-space></q-space>
<div class="row items-center q-gutter-sm">
<transition appear enter-active-class="animated bounceIn" leave-active-class="animated bounceOut">
<div v-if="selectedCells.size > 0" class="row items-center bg-blue-1 text-blue-9 q-px-md q-py-xs rounded-borders border border-blue-2 q-mr-md">
<div class="text-caption text-weight-bold q-mr-sm">{{ selectedCells.size }} selected</div>
<q-separator vertical inset class="q-mx-sm"></q-separator>
<q-btn flat round dense size="sm" icon="content_copy" @click="handleCopy"><q-tooltip>Copy (Ctrl+C)</q-tooltip></q-btn>
<q-btn flat round dense size="sm" icon="content_paste" @click="handlePaste" :disable="!clipboard"><q-tooltip>Paste (Ctrl+V)</q-tooltip></q-btn>
<q-btn flat round dense size="sm" icon="close" @click="clearSelection" class="q-ml-xs"></q-btn>
</div>
</transition>
<q-btn flat round dense icon="help_outline" color="grey-6">
<q-menu class="q-pa-md" style="min-width: 250px">
<div class="text-weight-bold q-mb-md">Shortcuts</div>
<div class="column q-gutter-y-xs text-caption">
<div class="row justify-between"><span>Select Block</span><q-badge color="grey-2" text-color="grey-9">Drag Mouse</q-badge></div>
<div class="row justify-between"><span>Edit Cell</span><q-badge color="grey-2" text-color="grey-9">Double Click</q-badge></div>
<div class="row justify-between"><span>Add Selection</span><q-badge color="grey-2" text-color="grey-9">Ctrl + Drag</q-badge></div>
<div class="row justify-between"><span>Clear</span><q-badge color="grey-2" text-color="grey-9">Esc</q-badge></div>
</div>
</q-menu>
</q-btn>
<q-avatar size="32px" class="q-ml-xs"><img src="https://i.pravatar.cc/150?u=me"></q-avatar>
</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawer" side="left" bordered :width="280" class="bg-white">
<div class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-md">Workspace</div>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label>Compact View</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="isCompact" color="indigo-8"></q-toggle></q-item-section>
</q-item>
<q-item tag="label" v-ripple class="q-px-none">
<q-item-section><q-item-label>Reading Crosshair</q-item-label><q-item-label caption>Hover highlight</q-item-label></q-item-section>
<q-item-section side><q-toggle v-model="crosshairActive" color="indigo-8"></q-toggle></q-item-section>
</q-item>
</div>
</q-drawer>
<q-drawer v-model="rightDrawer" side="right" bordered :width="350" overlay elevated>
<div class="column full-height bg-white">
<q-toolbar class="bg-grey-1 border-b">
<q-toolbar-title class="text-subtitle2 text-weight-bold">Assignment Editor</q-toolbar-title>
<q-btn flat round dense icon="close" size="sm" @click="rightDrawer = false"></q-btn>
</q-toolbar>
<q-scroll-area class="col q-pa-md">
<div v-if="selectedAgent && selectedDate" class="column q-gutter-y-md">
<div class="row items-center no-wrap">
<q-avatar size="48px"><img :src="selectedAgent.avatar"></q-avatar>
<div class="q-ml-md">
<div class="text-weight-bold">{{ selectedAgent.name }}</div>
<div class="text-caption text-grey-6">{{ formatDateForDisplay(selectedDate) }}</div>
</div>
</div>
<q-separator></q-separator>
<div class="text-overline">Shift Type</div>
<div class="row q-col-gutter-sm">
<div v-for="s in shifts" :key="s.id" class="col-6">
<q-btn :color="s.color" :outline="getAssignment(selectedAgent.id, selectedDate)?.id !== s.id" :unelevated="getAssignment(selectedAgent.id, selectedDate)?.id === s.id" :label="s.label" class="full-width" dense no-caps @click="updateAssignment(selectedAgent.id, selectedDate, s.id)"></q-btn>
</div>
<div class="col-12">
<q-btn outline color="red-4" label="Clear Shift" class="full-width q-mt-sm" dense no-caps @click="updateAssignment(selectedAgent.id, selectedDate, null)"></q-btn>
</div>
</div>
</div>
</q-scroll-area>
</div>
</q-drawer>
<q-page-container>
<q-page class="q-pa-none">
<div id="viewport-target" class="grid-viewport" ref="viewport">
<div class="planner-content">
<div class="planner-row row-dates">
<div class="left-col">
<q-input v-model="search" debounce="300" dense outlined rounded placeholder="Filter agents..." class="full-width" bg-color="white">
<template v-slot:prepend><q-icon name="search" size="xs"></q-icon></template>
</q-input>
</div>
<div class="cells-area">
<div v-for="(date, i) in dates" :key="'d'+i"
class="cell column flex-center"
:class="[isWeekend(date) ? 'bg-weekend' : '', crosshairActive && hoveredDateStr === formatDateForId(date) ? 'col-hovered' : '']"
style="height: var(--h-dates)"
@mouseenter="hoveredDateStr = formatDateForId(date)"
@mouseleave="hoveredDateStr = null">
<div class="text-[10px] uppercase text-weight-bold text-grey-5">{{ date.toLocaleDateString('en-US', {weekday: 'short'}) }}</div>
<div class="text-subtitle2 text-weight-bold text-grey-9">{{ date.getDate() }}. {{ date.toLocaleDateString('en-US', { month: 'short' }) }}</div>
</div>
</div>
</div>
<q-virtual-scroll
scroll-target="#viewport-target"
:items="flattenedList"
:item-size="isCompact ? 43 : 59"
>
<template v-slot="{ item, index }">
<div v-if="item.type === 'header-l1'" :key="item.id" class="planner-row group-header-l1">
<div class="left-col group-header-l1">{{ item.label }}</div>
<div class="cells-area"><div class="cell" style="flex: 1; border-right: none"></div></div>
</div>
<div v-else :key="item.id" class="planner-row planner-row-item">
<div class="left-col border-b" :class="[isCompact ? 'cell-compact' : '']">
<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">{{ item.data.role }}</div>
</div>
</div>
<div class="cells-area">
<div v-for="(date, i) in dates"
:key="'c'+item.data.id+i"
class="cell"
:class="getCellClass(item.data.id, date)"
@mouseenter="onCellMouseEnter(item.data.id, i, index)"
@mousedown.stop="onCellMouseDown(item.data.id, i, index, $event)"
@dblclick.stop="handleCellDblClick(item.data, date)">
<div v-if="getAssignment(item.data.id, date)" class="shift-badge shadow-1" :class="getAssignment(item.data.id, date).badgeClass">
{{ getAssignment(item.data.id, date).label }}
</div>
<div v-if="hasNote(item.data.id, date)" class="absolute-bottom-right q-pa-xs">
<q-icon name="info" size="8px" color="orange-4"></q-icon>
</div>
</div>
</div>
</div>
</template>
</q-virtual-scroll>
</div>
</div>
</q-page>
</q-page-container>
</q-layout>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.16.0/dist/quasar.umd.prod.js"></script>
<script>
const { createApp, ref, computed, reactive, onMounted, onBeforeUnmount } = Vue;
const { useQuasar } = Quasar;
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' }
];
app = createApp({
setup() {
const $q = useQuasar();
const leftDrawer = ref(false);
const rightDrawer = ref(false);
const search = ref("");
const isCompact = ref(false);
const crosshairActive = ref(false);
const hoveredDateStr = ref(null);
const agents = ref([]);
const assignments = reactive({});
const notes = reactive({});
const selectedAgent = ref(null);
const selectedDate = ref(null);
const selectedCells = ref(new Set());
const clipboard = ref(null);
const isSelecting = ref(false);
const dragStart = ref(null);
const dragCurrent = ref(null);
const selectionSnapshot = ref(new Set());
const formatDateForId = (date) => {
if (!date) return '';
const d = new Date(date);
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
};
const formatDateForDisplay = (d) => d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
const startDate = ref(new Date());
startDate.value.setDate(startDate.value.getDate() - (startDate.value.getDay() || 7) + 1);
const dates = computed(() => {
const res = [];
for (let i = 0; i < 28; i++) {
const d = new Date(startDate.value);
d.setDate(startDate.value.getDate() + i);
res.push(d);
}
return res;
});
const isWeekend = (date) => [0, 6].includes(date.getDay());
const onCellMouseDown = (agentId, hIdx, vIdx, event) => {
event.preventDefault();
if (window.getSelection) { window.getSelection().removeAllRanges(); }
isSelecting.value = true;
dragStart.value = { vIdx, hIdx };
dragCurrent.value = { vIdx, hIdx };
if (!event.ctrlKey && !event.metaKey) {
selectedCells.value.clear();
selectionSnapshot.value = new Set();
} else {
selectionSnapshot.value = new Set(selectedCells.value);
}
updateSelectionRect();
};
const onCellMouseEnter = (agentId, hIdx, vIdx) => {
hoveredDateStr.value = formatDateForId(dates.value[hIdx]);
if (!isSelecting.value) return;
dragCurrent.value = { vIdx, hIdx };
updateSelectionRect();
};
const onGlobalMouseUp = () => {
if (isSelecting.value) {
isSelecting.value = false;
dragStart.value = null;
dragCurrent.value = null;
}
};
const updateSelectionRect = () => {
if (!dragStart.value || !dragCurrent.value) return;
const minV = Math.min(dragStart.value.vIdx, dragCurrent.value.vIdx);
const maxV = Math.max(dragStart.value.vIdx, dragCurrent.value.vIdx);
const minH = Math.min(dragStart.value.hIdx, dragCurrent.value.hIdx);
const maxH = Math.max(dragStart.value.hIdx, dragCurrent.value.hIdx);
const gridItems = flattenedList.value;
const dateStrs = dates.value.map(d => formatDateForId(d));
const nextSelection = new Set(selectionSnapshot.value);
for (let v = minV; v <= maxV; v++) {
const row = gridItems[v];
if (row && row.type === 'agent') {
for (let h = minH; h <= maxH; h++) {
const dStr = dateStrs[h];
if (dStr) nextSelection.add(`${row.data.id}:${dStr}`);
}
}
}
selectedCells.value = nextSelection;
};
const handleCellDblClick = (agent, date) => {
selectedAgent.value = agent;
selectedDate.value = date;
rightDrawer.value = true;
};
const clearSelection = () => { selectedCells.value.clear(); };
const handleCopy = () => {
if (selectedCells.value.size === 0) return;
const selectedArray = Array.from(selectedCells.value);
const dateStrs = dates.value.map(d => formatDateForId(d));
const gridItems = flattenedList.value;
const items = selectedArray.map(key => {
const [aid, d] = key.split(':');
const agentId = parseInt(aid);
const vIdx = gridItems.findIndex(i => i.type === 'agent' && i.data.id === agentId);
const hIdx = dateStrs.indexOf(d);
return {
agentId, dateStr: d, vIdx, hIdx,
shift: assignments[agentId]?.[d] || null,
note: notes[agentId]?.[d] || null
};
});
const minVIdx = Math.min(...items.map(i => i.vIdx));
const minHIdx = Math.min(...items.map(i => i.hIdx));
clipboard.value = {
type: selectedCells.value.size === 1 ? 'single' : 'block',
items: items.map(i => ({
relV: i.vIdx - minVIdx, relH: i.hIdx - minHIdx,
shift: i.shift, note: i.note
}))
};
let msg = clipboard.value.type === 'single' ? 'Cell copied' : `${selectedCells.value.size} cells copied`;
$q.notify({ message: msg, color: 'indigo-8', icon: 'content_copy', position: 'bottom', timeout: 2000 });
};
const handlePaste = () => {
if (!clipboard.value || selectedCells.value.size === 0) return;
const gridItems = flattenedList.value;
const dateStrs = dates.value.map(d => formatDateForId(d));
const targetArray = Array.from(selectedCells.value);
const [firstAid, firstD] = targetArray[0].split(':');
const firstV = gridItems.findIndex(i => i.type === 'agent' && i.data.id === parseInt(firstAid));
const firstH = dateStrs.indexOf(firstD);
const nextSelection = new Set();
if (clipboard.value.type === 'single') {
const source = clipboard.value.items[0];
targetArray.forEach(key => {
const [aid, d] = key.split(':');
const agentId = parseInt(aid);
if (!assignments[agentId]) assignments[agentId] = {};
if (!notes[agentId]) notes[agentId] = {};
assignments[agentId][d] = source.shift;
notes[agentId][d] = source.note;
nextSelection.add(key);
});
} else {
clipboard.value.items.forEach(clipItem => {
const targetV = firstV + clipItem.relV;
const targetH = firstH + clipItem.relH;
const row = gridItems[targetV];
const dStr = dateStrs[targetH];
if (row && row.type === 'agent' && dStr) {
const agentId = row.data.id;
const key = `${agentId}:${dStr}`;
if (!assignments[agentId]) assignments[agentId] = {};
if (!notes[agentId]) notes[agentId] = {};
assignments[agentId][dStr] = clipItem.shift;
notes[agentId][dStr] = clipItem.note;
nextSelection.add(key);
}
});
}
selectedCells.value = nextSelection; // Select the pasted area
$q.notify({ message: 'Paste complete', color: 'positive', icon: 'check_circle', position: 'bottom', timeout: 1500 });
};
const updateAssignment = (aid, date, shiftId) => {
const dStr = formatDateForId(date);
if (!assignments[aid]) assignments[aid] = {};
assignments[aid][dStr] = shiftId;
};
const getAssignment = (aid, d) => {
const code = assignments[aid]?.[formatDateForId(d)];
return code ? SHIFTS.find(s => s.id === code) : null;
};
const hasNote = (aid, d) => notes[aid]?.[formatDateForId(d)];
const getCellClass = (aid, d) => {
const key = `${aid}:${formatDateForId(d)}`;
return {
'cell-selected': selectedCells.value.has(key),
'bg-weekend': isWeekend(d),
'cell-compact': isCompact.value,
'col-hovered': crosshairActive.value && hoveredDateStr.value === formatDateForId(d)
};
};
const flattenedList = computed(() => {
const res = [];
const filtered = agents.value.filter(a => a.name.toLowerCase().includes(search.value.toLowerCase()));
const hubs = [...new Set(filtered.map(a => a.hub))];
hubs.sort().forEach(h => {
res.push({ type: 'header-l1', label: h, id: `h-${h}` });
filtered.filter(a => a.hub === h).forEach(a => res.push({ type: 'agent', data: a, id: a.id }));
});
return res;
});
const onGlobalKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') { e.preventDefault(); handleCopy(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'v') { e.preventDefault(); handlePaste(); }
};
const onSelectStart = (e) => { if (isSelecting.value) e.preventDefault(); };
onMounted(() => {
agents.value = Array.from({ length: 800 }, (_, i) => ({
id: i + 1, name: `Agent ${i + 1}`, role: i % 3 === 0 ? 'Lead' : 'Specialist',
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
hub: i % 5 === 0 ? 'Central Hub' : 'Nordic Hub'
}));
window.addEventListener('keydown', onGlobalKeyDown);
window.addEventListener('mouseup', onGlobalMouseUp);
window.addEventListener('selectstart', onSelectStart);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeyDown);
window.removeEventListener('mouseup', onGlobalMouseUp);
window.removeEventListener('selectstart', onSelectStart);
});
return {
isSelecting, leftDrawer, rightDrawer, search, isCompact, crosshairActive, hoveredDateStr,
dates, flattenedList, formatDateForId, formatDateForDisplay, isWeekend,
selectedCells, onCellMouseDown, onCellMouseEnter, onGlobalMouseUp, handleCellDblClick, clearSelection,
getAssignment, hasNote, getCellClass,
clipboard, handleCopy, handlePaste,
selectedAgent, selectedDate, shifts: SHIFTS, updateAssignment
};
}
});
app.use(Quasar);
app.mount('#q-app');
</script>
</body>
</html>