refactor(task-modal): enhance employee timeblock management with custom filtering and improved rendering logic

This commit is contained in:
Matej Glasnák committed 2025-12-15 22:01:29 +01:00
1 parent 7daf559f1c
commit 8e9e586276
5 files changed
+858 -269

No files matched your search

@@ -1,12 +1,14 @@
<template>
<div class="employee-select">
<v-autocomplete
:key="`employee-select-${componentKey}`"
ref="employeeSelectRef"
:model-value="modelValue.id"
:custom-filter="customFilter"
:match-any="true"
label="Priradený"
:items="enrichedEmployees"
item-title="_fullName"
:item-title="getItemTitle"
item-value="id"
variant="solo"
class="employee-select__select-field"
@@ -40,7 +42,7 @@
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue';
import { computed, toRefs, watch, nextTick, ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { helpers, required } from '@vuelidate/validators';
@@ -63,7 +65,65 @@ const emit = defineEmits<{
const store = useStore();
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
const employeeSelectRef = ref<any>(null);
// Force re-render key
const componentKey = ref(0);
// Ensure all employees have a displayable name
const enrichedEmployees = computed(() => {
const employees = store.getters['tm/enrichedEmployees'] || [];
return employees.map((emp: any) => ({
...emp,
_fullName:
emp._fullName ||
`${emp.name || ''} ${emp.surname || ''}`.trim() ||
`Employee ${emp.id}`,
}));
});
// Function to get item title - handles both objects and IDs
const getItemTitle = (item: any): string => {
// If item is an object with _fullName, return it
if (item && typeof item === 'object' && item._fullName) {
return item._fullName;
}
// If item is just an ID (string or number), find the employee
if (item && (typeof item === 'string' || typeof item === 'number')) {
const employee = enrichedEmployees.value.find((emp) => emp.id == item);
return employee?._fullName || `Employee ${item}`;
}
return '';
};
// Watch for when employees list changes to force re-render
watch(
() => enrichedEmployees.value.length,
(newLength, oldLength) => {
// Force component re-render when employees are loaded
if (newLength > 0 && newLength !== oldLength) {
nextTick(() => {
componentKey.value++;
});
}
},
{ immediate: true },
);
// Watch for when the selected employee ID changes to ensure display updates
watch(
() => props.modelValue.id,
(newId) => {
// When ID is set and we have employees, force re-render to ensure proper display
if (newId && enrichedEmployees.value.length > 0) {
nextTick(() => {
componentKey.value++;
});
}
},
);
// Make modelValue reactive for Vuelidate
const { modelValue } = toRefs(props);
@@ -150,6 +210,12 @@ const validate = async (required = false) => {
return isValid;
};
onMounted(() => {
setTimeout(() => {
console.log('Employee select ref:', employeeSelectRef.value);
}, 1000);
});
defineExpose({
validate,
});
@@ -11,7 +11,13 @@
:hour-width="hourWidth"
/>
<div class="timeline-bar">
<div
ref="timelineBar"
class="timeline-bar"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
>
<!-- Non-working hours - before work -->
<div
class="non-working-hours"
@@ -47,7 +53,14 @@
:key="`task-${index}`"
class="scheduled-block"
:class="{
'scheduled-block--blocker': ['blocker', 'adhoc', 'home_office', 'vacation', 'free_day', 'office365_blocker'].includes(task.type)
'scheduled-block--blocker': [
'blocker',
'adhoc',
'home_office',
'vacation',
'free_day',
'office365_blocker',
].includes(task.type),
}"
:style="{
left: `${getTimePosition(task._real_start)}px`,
@@ -68,11 +81,34 @@
:title="`Other timeblock`"
></div>
<!-- Hover preview block -->
<div
v-if="showPreview && previewBlock"
class="preview-block"
:class="{ 'is-stretching': dragState.isStretching }"
:style="{
left: `${getTimePosition(previewBlock.start)}px`,
width: `${getTimeWidth(previewBlock.start, previewBlock.end)}px`,
}"
@mousedown.stop="handlePreviewMouseDown"
>
<div class="preview-time-label">
{{ formatTime(previewBlock.start) }} -
{{ formatTime(previewBlock.end) }}
</div>
</div>
<!-- Current timeblock (if valid) -->
<div
v-if="currentTimeblockRange.start && currentTimeblockRange.end"
v-if="
currentTimeblockRange.start &&
currentTimeblockRange.end &&
!dragState.isResizingCurrent
"
class="current-timeblock"
:class="{ 'has-collision': collision.hasCollision }"
:class="{
'has-collision': collision.hasCollision,
}"
:style="{
left: `${getTimePosition(currentTimeblockRange.start)}px`,
width: `${getTimeWidth(
@@ -85,6 +121,35 @@
<div v-if="timeblockDuration > 0" class="timeblock-duration">
{{ timeblockDuration.toFixed(2) }}h
</div>
<!-- Resize handles -->
<div
class="resize-handle resize-handle--left"
@mousedown.stop="handleResizeMouseDown($event, 'left')"
></div>
<div
class="resize-handle resize-handle--right"
@mousedown.stop="handleResizeMouseDown($event, 'right')"
></div>
</div>
<!-- Resizing current timeblock preview -->
<div
v-if="dragState.isResizingCurrent && resizedCurrentBlock"
class="current-timeblock is-resizing"
:class="{ 'has-collision': collision.hasCollision }"
:style="{
left: `${getTimePosition(resizedCurrentBlock.start)}px`,
width: `${getTimeWidth(
resizedCurrentBlock.start,
resizedCurrentBlock.end,
)}px`,
}"
>
<div class="preview-time-label">
{{ formatTime(resizedCurrentBlock.start) }} -
{{ formatTime(resizedCurrentBlock.end) }}
</div>
</div>
<!-- Collision segments -->
@@ -104,7 +169,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import moment from 'moment';
import AEmployeeTimeblockTimelineHeader from './a-employee-timeblock-timeline-header.vue';
import type { EmployeeFullData, TimeblockData } from './types';
@@ -114,6 +179,7 @@ interface TimelineTask {
_real_end: Date;
subtask?: { title: string };
blocker?: { type: string };
type?: string;
}
interface CollisionSegment {
@@ -146,8 +212,32 @@ const props = withDefaults(defineProps<Props>(), {
otherTimeblocks: () => [],
});
const emit = defineEmits<{
(
e: 'timeblockUpdated',
payload: { starts_at_time: string; ends_at_time: string },
): void;
}>();
const timelineContainer = ref<HTMLElement | null>(null);
const timelineScroll = ref<HTMLElement | null>(null);
const timelineBar = ref<HTMLElement | null>(null);
// Check if mobile device
const isMobile = ref(false);
// Interactive state
const showPreview = ref(false);
const previewBlock = ref<{ start: Date; end: Date } | null>(null);
const dragState = ref({
isStretching: false,
isResizingCurrent: false,
resizeEdge: null as 'left' | 'right' | null,
startX: 0,
initialStart: null as Date | null,
initialEnd: null as Date | null,
});
const resizedCurrentBlock = ref<{ start: Date; end: Date } | null>(null);
// Convert other timeblocks to renderable format
const otherTimeblocksRendered = computed(() => {
@@ -192,6 +282,303 @@ const getTimeWidth = (start: Date, end: Date): number => {
return (endHour - startHour) * props.hourWidth - 2;
};
// Time snapping utility - snap to 15 minute intervals
const snapToInterval = (date: Date, intervalMinutes = 15): Date => {
const minutes = date.getMinutes();
const snappedMinutes =
Math.round(minutes / intervalMinutes) * intervalMinutes;
return moment(date)
.set({ minute: snappedMinutes, second: 0, millisecond: 0 })
.toDate();
};
// Convert pixel position to time
const positionToTime = (pixelX: number): Date => {
const hoursFromStart = pixelX / props.hourWidth;
const totalHours = props.startHour + hoursFromStart;
const hour = Math.floor(totalHours);
const minute = (totalHours % 1) * 60;
// Use current date from currentTimeblockRange or today
const baseDate = props.currentTimeblockRange.start
? moment(props.currentTimeblockRange.start)
: moment();
return baseDate.set({ hour, minute, second: 0, millisecond: 0 }).toDate();
};
// Format time as HH:mm
const formatTime = (date: Date): string => {
return moment(date).format('HH:mm');
};
// Check if mouse is over an existing block (x is already relative position with scroll)
const isOverExistingBlock = (relativeX: number): boolean => {
// Check if over any scheduled block
for (const task of props.scheduledBlocks) {
const blockLeft = getTimePosition(task._real_start);
const blockWidth = getTimeWidth(task._real_start, task._real_end);
if (relativeX >= blockLeft && relativeX <= blockLeft + blockWidth) {
return true;
}
}
// Check if over any other timeblock
for (const tb of otherTimeblocksRendered.value) {
const blockLeft = getTimePosition(tb._start);
const blockWidth = getTimeWidth(tb._start, tb._end);
if (relativeX >= blockLeft && relativeX <= blockLeft + blockWidth) {
return true;
}
}
// Check if over current timeblock
if (props.currentTimeblockRange.start && props.currentTimeblockRange.end) {
const blockLeft = getTimePosition(props.currentTimeblockRange.start);
const blockWidth = getTimeWidth(
props.currentTimeblockRange.start,
props.currentTimeblockRange.end,
);
if (relativeX >= blockLeft && relativeX <= blockLeft + blockWidth) {
return true;
}
}
return false;
};
// Mouse move handler - show preview
const handleMouseMove = (event: MouseEvent) => {
// Disable hover preview on mobile
if (isMobile.value) {
return;
}
if (dragState.value.isStretching) {
handleStretchMove(event);
return;
}
if (dragState.value.isResizingCurrent) {
handleResizeCurrentMove(event);
return;
}
if (!timelineScroll.value) return;
// Get position relative to scroll container, accounting for scroll
const rect = timelineScroll.value.getBoundingClientRect();
const scrollLeft = timelineScroll.value.scrollLeft;
const x = event.clientX - rect.left + scrollLeft;
// Don't show preview if over existing block (using relative position)
if (isOverExistingBlock(x)) {
showPreview.value = false;
return;
}
// Convert to time and snap
const rawTime = positionToTime(x);
const snappedStart = snapToInterval(rawTime);
const snappedEnd = moment(snappedStart).add(1, 'hour').toDate();
// Only show preview within work hours
const startHour = snappedStart.getHours() + snappedStart.getMinutes() / 60;
const endHour = snappedEnd.getHours() + snappedEnd.getMinutes() / 60;
if (
startHour >= props.employee.work_start &&
endHour <= props.employee.work_end
) {
showPreview.value = true;
previewBlock.value = {
start: snappedStart,
end: snappedEnd,
};
} else {
showPreview.value = false;
}
};
// Mouse leave handler
const handleMouseLeave = () => {
if (!dragState.value.isStretching && !dragState.value.isResizingCurrent) {
showPreview.value = false;
}
};
// Mouse down on timeline bar (not on preview or current block)
const handleMouseDown = (_event: MouseEvent) => {
// This will only trigger if not clicking on preview or current block
// (those have @mousedown.stop)
};
// Preview block mouse down - start creating/stretching
const handlePreviewMouseDown = (event: MouseEvent) => {
if (!previewBlock.value) return;
dragState.value.isStretching = true;
dragState.value.startX = event.clientX;
dragState.value.initialStart = previewBlock.value.start;
dragState.value.initialEnd = previewBlock.value.end;
event.preventDefault();
};
// Handle stretch move
const handleStretchMove = (event: MouseEvent) => {
if (!dragState.value.initialStart || !timelineScroll.value) return;
const rect = timelineScroll.value.getBoundingClientRect();
const x = event.clientX - rect.left + timelineScroll.value.scrollLeft;
const currentTime = positionToTime(x);
const snappedTime = snapToInterval(currentTime);
const initialStartTime = dragState.value.initialStart.getTime();
const snappedTimeValue = snappedTime.getTime();
if (snappedTimeValue > initialStartTime) {
// Stretching right (extending end)
previewBlock.value = {
start: dragState.value.initialStart,
end: snappedTime,
};
} else {
// Stretching left (extending start)
previewBlock.value = {
start: snappedTime,
end: dragState.value.initialStart,
};
}
};
// Handle stretch end
const handleStretchEnd = () => {
if (dragState.value.isStretching && previewBlock.value) {
// Emit the new timeblock
emit('timeblockUpdated', {
starts_at_time: formatTime(previewBlock.value.start),
ends_at_time: formatTime(previewBlock.value.end),
});
}
dragState.value.isStretching = false;
dragState.value.initialStart = null;
dragState.value.initialEnd = null;
showPreview.value = false;
};
// Resize handle mouse down - start resizing
const handleResizeMouseDown = (event: MouseEvent, edge: 'left' | 'right') => {
if (!props.currentTimeblockRange.start || !props.currentTimeblockRange.end)
return;
dragState.value.isResizingCurrent = true;
dragState.value.resizeEdge = edge;
dragState.value.startX = event.clientX;
dragState.value.initialStart = props.currentTimeblockRange.start;
dragState.value.initialEnd = props.currentTimeblockRange.end;
resizedCurrentBlock.value = {
start: props.currentTimeblockRange.start,
end: props.currentTimeblockRange.end,
};
event.preventDefault();
};
// Handle resize current block move
const handleResizeCurrentMove = (event: MouseEvent) => {
if (
!dragState.value.initialStart ||
!dragState.value.initialEnd ||
!timelineScroll.value
)
return;
const rect = timelineScroll.value.getBoundingClientRect();
const x = event.clientX - rect.left + timelineScroll.value.scrollLeft;
const currentTime = positionToTime(x);
const snappedTime = snapToInterval(currentTime);
if (dragState.value.resizeEdge === 'left') {
// Resizing left edge (changing start time)
// Make sure new start is before end
if (snappedTime < dragState.value.initialEnd) {
resizedCurrentBlock.value = {
start: snappedTime,
end: dragState.value.initialEnd,
};
}
} else if (dragState.value.resizeEdge === 'right') {
// Resizing right edge (changing end time)
// Make sure new end is after start
if (snappedTime > dragState.value.initialStart) {
resizedCurrentBlock.value = {
start: dragState.value.initialStart,
end: snappedTime,
};
}
}
};
// Handle resize current block end
const handleResizeCurrentEnd = () => {
if (dragState.value.isResizingCurrent && resizedCurrentBlock.value) {
// Emit the updated timeblock
emit('timeblockUpdated', {
starts_at_time: formatTime(resizedCurrentBlock.value.start),
ends_at_time: formatTime(resizedCurrentBlock.value.end),
});
}
dragState.value.isResizingCurrent = false;
dragState.value.resizeEdge = null;
dragState.value.initialStart = null;
dragState.value.initialEnd = null;
resizedCurrentBlock.value = null;
};
// Global mouse up handler
const handleGlobalMouseUp = () => {
if (dragState.value.isStretching) {
handleStretchEnd();
} else if (dragState.value.isResizingCurrent) {
handleResizeCurrentEnd();
}
};
// Global mouse move handler
const handleGlobalMouseMove = (event: MouseEvent) => {
if (dragState.value.isStretching) {
handleStretchMove(event);
} else if (dragState.value.isResizingCurrent) {
handleResizeCurrentMove(event);
}
};
// Setup global listeners
onMounted(() => {
// Detect mobile device
isMobile.value = window.innerWidth <= 768;
// Listen for resize to update mobile status
const handleResize = () => {
isMobile.value = window.innerWidth <= 768;
};
window.addEventListener('resize', handleResize);
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('mousemove', handleGlobalMouseMove);
});
onUnmounted(() => {
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('mousemove', handleGlobalMouseMove);
});
// Expose refs for parent component
defineExpose({
timelineScroll,
@@ -230,6 +617,7 @@ defineExpose({
width: calc(24 * 60px)
background: #fafafa
border-radius: 4px
cursor: crosshair
@media (max-width: 768px)
width: calc(24 * 50px)
@@ -240,6 +628,7 @@ defineExpose({
height: 100%
background: repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(224, 224, 224, 0.3) 10px, rgba(224, 224, 224, 0.3) 20px)
z-index: 1
pointer-events: none
.work-hours-bar
position: absolute
@@ -247,6 +636,7 @@ defineExpose({
height: 100%
background: transparent
z-index: 1
pointer-events: none
.scheduled-block
position: absolute
@@ -257,6 +647,7 @@ defineExpose({
opacity: 0.6
border-radius: 2px
z-index: 2
pointer-events: none
&--blocker
background: rgb(176, 0, 32)
@@ -272,6 +663,40 @@ defineExpose({
border: 2px dashed #FF9800
border-radius: 2px
z-index: 2
pointer-events: none
.preview-block
position: absolute
top: 5px
height: 22px
background: #4CAF50
opacity: 0.6
border: 2px dashed #388E3C
border-radius: 2px
z-index: 5
cursor: crosshair
transition: opacity 0.2s, box-shadow 0.2s
display: flex
align-items: center
justify-content: center
&:hover
opacity: 0.8
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.4)
&.is-stretching
opacity: 0.8
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.6)
cursor: ew-resize
.preview-time-label
font-size: 11px
font-weight: 600
color: white
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5)
white-space: nowrap
pointer-events: none
user-select: none
.current-timeblock
position: absolute
@@ -282,10 +707,37 @@ defineExpose({
border: 2px solid #1976D2
border-radius: 2px
z-index: 3
transition: box-shadow 0.2s
&.has-collision
border-color: #ff5252
&.is-resizing
opacity: 0.8
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.6)
z-index: 6
.resize-handle
position: absolute
top: 0
width: 8px
height: 100%
cursor: ew-resize
z-index: 4
&:hover
background: rgba(255, 255, 255, 0.3)
&--left
left: 0
border-top-left-radius: 2px
border-bottom-left-radius: 2px
&--right
right: 0
border-top-right-radius: 2px
border-bottom-right-radius: 2px
.collision-segment
position: absolute
top: 5px
@@ -294,6 +746,7 @@ defineExpose({
opacity: 0.6
border-radius: 2px
z-index: 4
pointer-events: none
.timeblock-duration
position: absolute
@@ -305,6 +758,7 @@ defineExpose({
color: white
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3)
pointer-events: none
user-select: none
.timeline-loading
height: 60px
@@ -39,6 +39,7 @@
:start-hour="START_HOUR"
:end-hour="END_HOUR"
:hour-width="hourWidth"
@timeblock-updated="handleTimeblockUpdated"
/>
</div>
</template>
@@ -269,24 +270,12 @@ const otherTimeblocksForSameDate = computed(() => {
if (!props.modelValue.date) return [];
const currentDate = moment(props.modelValue.date).format('YYYY-MM-DD');
const currentTimeblock = props.modelValue;
// Use allTimeblocks if available (includes all employees), otherwise fall back to current employee only
const timeblocksToCheck =
props.allTimeblocks.length > 0
? props.allTimeblocks
: props.allEmployeeTimeblocks;
return timeblocksToCheck.filter((tb) => {
// Skip current timeblock - compare by reference or by matching all key properties
if (
tb === currentTimeblock ||
(tb.employee_id == currentTimeblock.employee_id &&
tb.date === currentTimeblock.date &&
tb.starts_at_time === currentTimeblock.starts_at_time &&
tb.ends_at_time === currentTimeblock.ends_at_time &&
tb.id === currentTimeblock.id)
) {
// Use allEmployeeTimeblocks which contains timeblocks for this specific employee
// Filter out the current timeblock by comparing index directly
return props.allEmployeeTimeblocks.filter((tb, index) => {
// Skip current timeblock by index
if (index === props.currentIndex) {
return false;
}
@@ -405,6 +394,23 @@ const updateEndTime = (event: Event) => {
});
};
// Handle timeblock updated from timeline (drag/drop or quick add)
const handleTimeblockUpdated = (payload: {
starts_at_time: string;
ends_at_time: string;
}) => {
emit('update:modelValue', {
...props.modelValue,
starts_at_time: payload.starts_at_time,
ends_at_time: payload.ends_at_time,
});
// Trigger validation after timeline update
nextTick(() => {
v$.value.$touch();
});
};
// Expose validate method
const validate = async () => {
v$.value.$touch();
+41 -2
View File
@@ -102,7 +102,7 @@
:timeblocks="getEmployeeTimeblocks(subtask.employee_id)"
:all-timeblocks="timeblocks"
:deleted-timeblock-ids="deletedTimeblockIds"
:custom-filter="customFilter"
:custom-filter="getEmployeeCustomFilter(subtask.employee_id)"
@update:timeblocks="
updateEmployeeTimeblocks($event, subtask.employee_id)
"
@@ -122,7 +122,7 @@
:timeblocks="getEmployeeTimeblocks(employee.id)"
:all-timeblocks="timeblocks"
:deleted-timeblock-ids="deletedTimeblockIds"
:custom-filter="customFilter"
:custom-filter="getEmployeeCustomFilter(employee.id)"
:required="index === 0"
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
@clear-employee="() => removeEmployeeAtIndex(index)"
@@ -564,6 +564,45 @@ export default {
return this.timeblocks.filter((tb) => tb.employee_id == employeeId);
},
// Custom filter for employee select to prevent selecting the same employee twice
// currentEmployeeId: the ID of the employee currently selected in this dropdown (can be null)
getEmployeeCustomFilter(currentEmployeeId) {
return (itemText, queryText, item) => {
// Get all selected employee IDs except the current one
const selectedEmployeeIds = new Set();
if (this.isDraftWithMultipleEmployees && this.subtask?.employees) {
// In multi-employee mode, collect all selected employee IDs except current
this.subtask.employees.forEach((emp) => {
if (emp.id && emp.id !== currentEmployeeId) {
selectedEmployeeIds.add(emp.id);
}
});
} else if (
this.subtask?.employee_id &&
this.subtask.employee_id !== currentEmployeeId
) {
// In single employee edit mode
selectedEmployeeIds.add(this.subtask.employee_id);
}
// Filter out already selected employees (but allow current selection)
if (selectedEmployeeIds.has(item.id)) {
return false;
}
// Apply default text filtering (from AutocompleteMixin)
if (this.customFilter) {
return this.customFilter(itemText, queryText, item);
}
// Default case-insensitive text match
const searchText = queryText.toString().toLowerCase();
const text = itemText.toString().toLowerCase();
return text.indexOf(searchText) > -1;
};
},
updateEmployeeTimeblocks(updatedTimeblocks, employeeId) {
// Track deleted timeblocks (saved timeblocks that are no longer in the updated list)
const updatedIds = new Set(