feat(task-modal): added timeline to timeblocks in employee modal

This commit is contained in:
Matej Glasnák committed 2025-12-08 03:27:21 +01:00
1 parent 00c4bf4d65
commit c55c2ce3e9
7 files changed
+468 -142

No files matched your search

+119 -84
View File
@@ -1,4 +1,10 @@
<template>
<z-remove-employee-modal
ref="removeEmployeeModal"
@confirm="confirmRemoveEmployee"
/>
<z-remove-timeblock-modal ref="removeTimeblockModal" />
<v-dialog
v-model="visible"
:width="modalWidth"
@@ -113,6 +119,7 @@
v-model:employee-data="subtask.employees[index]"
:timeblocks="getEmployeeTimeblocks(employee.id)"
:custom-filter="customFilter"
:required="index === 0"
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
@clear-employee="() => removeEmployeeAtIndex(index)"
@add-timeblock="
@@ -276,6 +283,8 @@ import modalMixin from '@/plugins/tm@modal/_mixins/modal.mixin';
import AResizeHandle from '@/plugins/tm@modal/_components/a-resize-handle.vue';
import AResetSizeButton from '@/plugins/tm@modal/_components/a-reset-size-button.vue';
import ACloseButton from '@/plugins/tm@modal/_components/a-close-button.vue';
import ZRemoveEmployeeModal from './z-remove-employee-modal.vue';
import ZRemoveTimeblockModal from './z-remove-timeblock-modal.vue';
export default {
components: {
@@ -288,6 +297,8 @@ export default {
AResizeHandle,
AResetSizeButton,
ACloseButton,
ZRemoveEmployeeModal,
ZRemoveTimeblockModal,
},
mixins: [SelectsMixin, AutocompleteMixin, modalMixin],
emits: ['close', 'refresh'],
@@ -307,6 +318,8 @@ export default {
wasFormValidationTriggered: false,
modalName: 'z-task-modal',
employeeRefs: [],
pendingRemoveEmployeeId: undefined,
pendingRemoveEmployeeIndex: undefined,
};
},
@@ -355,34 +368,13 @@ export default {
},
'subtask.employees': {
handler(newEmployees, oldEmployees) {
handler() {
if (!this.subtask?.employees) return;
const lastEmployee =
this.subtask.employees[this.subtask.employees.length - 1];
if (lastEmployee?.id || lastEmployee?.duration)
this.subtask.employees.push({});
// Auto-create first timeblock when employee gets both id and duration
if (newEmployees && oldEmployees) {
newEmployees.forEach((newEmp, index) => {
const oldEmp = oldEmployees[index] || {};
// Check if employee just became "complete" (has both id and duration)
const wasComplete = oldEmp.id && oldEmp.duration;
const isComplete = newEmp.id && newEmp.duration;
if (!wasComplete && isComplete) {
// Check if this employee already has timeblocks
const hasTimeblocks = this.timeblocks.some(
(tb) => tb.employee_id === newEmp.id,
);
if (!hasTimeblocks) {
// Add first timeblock for this employee
this.addTimeblock(newEmp.id);
}
}
});
}
},
deep: true,
},
@@ -418,9 +410,24 @@ export default {
},
deep: true,
},
timeblocks: {
handler(val) {
if (this.editMode && !this.subtask?.id) {
// Save timeblocks to localStorage for new tasks
localStorage.setItem('subtaskTimeblocks', JSON.stringify(val));
}
},
deep: true,
},
},
mounted() {
// Handle remove timeblock modal
this.$events.on('show-remove-timeblock-modal', ({ onConfirm }) => {
this.$refs.removeTimeblockModal.showModal({ onConfirm });
});
this.$events.on('add-timeblock', (timeblock) => {
if (!this.subtask?.timeblocks) {
this.subtask.timeblocks = [];
@@ -597,46 +604,51 @@ export default {
// Handle remove employee - also removes their timeblocks
handleRemoveEmployee() {
if (this.subtask.employee_id) {
// Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== this.subtask.employee_id,
);
}
// Clear employee data in edit mode
this.subtask.employee_id = null;
this.subtask.duration = null;
if (this.wasFormValidationTriggered) {
setTimeout(async () => {
this.validateAllFields();
}, 100);
}
this.pendingRemoveEmployeeId = this.subtask.employee_id;
this.$refs.removeEmployeeModal.showModal();
},
removeEmployeeAtIndex(index) {
const employee = this.subtask.employees[index];
if (employee?.id) {
// Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employee.id,
);
this.pendingRemoveEmployeeIndex = index;
this.$refs.removeEmployeeModal.showModal();
},
confirmRemoveEmployee() {
// Handle edit mode (single employee removal)
if (this.pendingRemoveEmployeeId !== undefined) {
if (this.subtask.employee_id) {
// Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== this.subtask.employee_id,
);
}
// Clear employee data in edit mode
this.subtask.employee_id = null;
this.subtask.duration = null;
this.pendingRemoveEmployeeId = undefined;
}
// Handle create mode (multiple employees removal)
else if (this.pendingRemoveEmployeeIndex !== undefined) {
const employee =
this.subtask.employees[this.pendingRemoveEmployeeIndex];
if (employee?.id) {
// Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employee.id,
);
}
// Remove employee from array
this.subtask.employees.splice(index, 1);
if (this.wasFormValidationTriggered) {
setTimeout(async () => {
this.validateAllFields();
}, 100);
// Remove employee from array
this.subtask.employees.splice(this.pendingRemoveEmployeeIndex, 1);
this.pendingRemoveEmployeeIndex = undefined;
}
},
async showModal({ subtask, editMode = false, mastertask = {} }) {
this.isSaving = false;
this.subtask = null;
this.wasFormValidationTriggered = false; // Reset validation trigger
// CRITICAL: Prevent v-dialog autofocus BEFORE modal opens
this.preventInputFocus = true;
@@ -710,6 +722,8 @@ export default {
this.subtask = this._enrichTask(data);
} else {
const savedSubtask = localStorage.getItem('subtaskData');
const savedTimeblocks = localStorage.getItem('subtaskTimeblocks');
try {
if (savedSubtask) {
const parsedSubtask = JSON.parse(savedSubtask);
@@ -718,6 +732,23 @@ export default {
if (typeof this.subtask !== 'object' || this.subtask === null) {
throw new Error('Parsed subtask is not an object');
}
// Load timeblocks from localStorage if they exist
if (savedTimeblocks) {
try {
const parsedTimeblocks = JSON.parse(savedTimeblocks);
if (Array.isArray(parsedTimeblocks)) {
this.timeblocks = parsedTimeblocks.map((tb) => ({
...tb,
date: tb.date ? new Date(tb.date) : null,
}));
}
} catch (e) {
console.error('Error parsing saved timeblocks:', e);
this.timeblocks = [];
}
}
this.$nextTick(() => {
this.$refs['description']?.setHTML(
this.subtask.description || '',
@@ -767,7 +798,7 @@ export default {
this.$router.replace({ name: this.$route.name });
},
async validateAllFields(concept = false) {
async validate(concept = false) {
if (concept === false) {
const employeesValid = await this._validateEmployees();
const timeblocksValid = await this._validateTimeblocks();
@@ -786,7 +817,7 @@ export default {
async save(concept = false) {
this.wasFormValidationTriggered = true;
if (!(await this.validateAllFields(concept))) return;
if (!(await this.validate(concept))) return;
this.subtask.description = this.$refs['description'].getHTML();
this._mergeDeadlineTimeDate();
@@ -806,12 +837,15 @@ export default {
});
// Convert timeblocks from internal format to BE format for update
// Filter out completely empty timeblocks AND timeblocks without all required fields
const timeblocks = this.timeblocks
.filter(
(timeblock) =>
timeblock.date &&
timeblock.starts_at_time &&
timeblock.ends_at_time &&
timeblock.employee_id === this.subtask.employee_id,
) // Only send timeblocks for this employee
) // Only send complete timeblocks for this employee
.map((timeblock) => {
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
return {
@@ -839,6 +873,7 @@ export default {
payload,
);
localStorage.removeItem('subtaskData');
localStorage.removeItem('subtaskTimeblocks');
this.$store.commit('tm/update_subtask', subtask);
this.$events.emit('on-subtask-edited', this.subtask);
@@ -849,11 +884,15 @@ export default {
);
// Assign timeblocks to each employee based on employee_id
// Filter out empty/incomplete timeblocks - only send fully filled timeblocks
validEmployees.forEach((employee) => {
employee.timeblocks = this.timeblocks
.filter(
(timeblock) =>
timeblock.date && timeblock.employee_id === employee.id,
timeblock.date &&
timeblock.starts_at_time &&
timeblock.ends_at_time &&
timeblock.employee_id === employee.id,
)
.map((timeblock) => {
return {
@@ -881,6 +920,7 @@ export default {
subtaskCopy,
);
localStorage.removeItem('subtaskData');
localStorage.removeItem('subtaskTimeblocks');
this.subtask.id = subtasks[0].id;
subtasks.forEach((subtask) => {
@@ -941,51 +981,47 @@ export default {
async _validateEmployees() {
if (this.subtask.id) {
// For existing subtasks, at least one employee is required
// Component-level validation will handle the conditional validation
const hasEmployee = !!(
this.subtask.employee_id && this.subtask.duration
);
if (!hasEmployee) {
this.$toast.error('Je potrebné vybrať zamestnanca a trvanie');
return false;
}
return hasEmployee;
} else {
// For new subtasks, require at least one complete employee (id + duration)
const validEmployees = this.subtask.employees.filter(
(employee) => employee.id && employee.duration,
);
let valid = true;
if (validEmployees.length === 0) {
this.$toast.error(
'Je potrebné pridať aspoň jedného zamestnanca s trvaním',
);
return false;
}
// Validate ALL employee component refs (this will show errors on inputs)
for (let i = 0; i < this.employeeRefs.length; i++) {
const employeeRef = this.employeeRefs[i];
// Validate all employee component refs (includes employee select and timeblocks validation)
for (const employeeRef of this.employeeRefs) {
// Validate employees that have any data (partial or complete)
if (employeeRef && typeof employeeRef.validate === 'function') {
const isValid = await employeeRef.validate();
if (!isValid) {
return false;
if (!(await employeeRef.validate())) {
valid = false;
}
}
}
}
return true;
return valid;
}
},
async _validateTimeblocks() {
// Validate that all timeblocks have required fields and valid time ranges
// Date alone is valid. Validation only applies if start OR end time is filled.
// If start OR end is filled, then ALL fields (date, start, end) are required.
let valid = true;
for (const timeblock of this.timeblocks) {
if (
!timeblock.date ||
!timeblock.starts_at_time ||
!timeblock.ends_at_time
) {
const hasStart = !!timeblock.starts_at_time;
const hasEnd = !!timeblock.ends_at_time;
const hasAnyTime = hasStart || hasEnd;
// If no time data (start/end), timeblock is valid (date alone is OK)
if (!hasAnyTime) {
continue;
}
// If start OR end is filled, ALL fields are required
const hasDate = !!timeblock.date;
if (!hasDate || !hasStart || !hasEnd) {
valid = false;
break;
}
@@ -1002,7 +1038,6 @@ export default {
);
if (end.isSameOrBefore(start)) {
this.$toast.error('Koniec času musí byť po začiatku');
valid = false;
break;
}