mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 06:20:21 +00:00
1165 lines
36 KiB
Vue
1165 lines
36 KiB
Vue
<template>
|
|
<z-remove-employee-modal
|
|
ref="removeEmployeeModal"
|
|
@confirm="confirmRemoveEmployee"
|
|
/>
|
|
<z-remove-timeblock-modal ref="removeTimeblockModal" />
|
|
|
|
<v-dialog
|
|
v-model="visible"
|
|
:width="modalWidth"
|
|
:height="modalHeight"
|
|
persistent
|
|
:retain-focus="false"
|
|
>
|
|
<W-loadable :content="subtask" class="h-100">
|
|
<v-card
|
|
v-if="editMode"
|
|
height="100%"
|
|
class="d-flex flex-column scrolling"
|
|
style="position: relative"
|
|
>
|
|
<div class="d-flex justify-space-between align-center modal-header">
|
|
<h1>{{ subtask?.id ? 'Upraviť sub task' : 'Vytvoriť sub task' }}</h1>
|
|
<div class="d-flex align-center">
|
|
<a-reset-size-button @reset="resetModalSize" />
|
|
<a-close-button @close="closeModal()" />
|
|
</div>
|
|
</div>
|
|
|
|
<v-card-actions class="d-flex flex-column modal-content">
|
|
<v-text-field
|
|
v-model="subtask.project_code"
|
|
label="Kód projektu"
|
|
:value="subtask.project_code"
|
|
class="modal-input"
|
|
variant="solo"
|
|
disabled
|
|
></v-text-field>
|
|
|
|
<v-autocomplete
|
|
v-model="subtask.master_task_id"
|
|
:custom-filter="customFilter"
|
|
:match-any="true"
|
|
label="Master task"
|
|
:class="{ error: v$.subtask.master_task_id.$errors.length }"
|
|
:items="enrichedMastertasks"
|
|
:item-title="(item) => item._fullName || item.name"
|
|
item-value="id"
|
|
variant="solo"
|
|
class="modal-input"
|
|
clearable
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="subtask.title"
|
|
class="modal-input"
|
|
label="Názov subtasku"
|
|
variant="solo"
|
|
></v-text-field>
|
|
|
|
<div class="splitted-inputs">
|
|
<!-- TODO: Bialko validacie -->
|
|
<div class="left">
|
|
<Z-datepicker
|
|
ref="datepicker-01"
|
|
v-model="subtask.deadline"
|
|
label="Deadline"
|
|
:auto-apply="true"
|
|
:min-date="new Date()"
|
|
class="modal-input"
|
|
format="dd.MM.yyyy"
|
|
locale="sk"
|
|
select-text="Vybrať"
|
|
cancel-text="Zrušiť"
|
|
placeholder="Deadline"
|
|
:class="{ error: v$.subtask.deadline.$errors?.length }"
|
|
:validations="v$.subtask.deadline"
|
|
/>
|
|
</div>
|
|
|
|
<v-text-field
|
|
v-model="subtask._deadline_time"
|
|
class="modal-input right"
|
|
variant="solo"
|
|
label="Čas"
|
|
append-inner-icon="mdi-clock-outline"
|
|
type="time"
|
|
:class="{ error: v$.subtask._deadline_time.$errors.length }"
|
|
:hint="
|
|
v$.subtask._deadline_time.$errors
|
|
? errorUtils.parseErrors(v$.subtask._deadline_time.$errors)
|
|
: null
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Employee Assignment with Timeblocks -->
|
|
<!-- Edit Mode: Single Employee -->
|
|
<A-employee
|
|
v-if="subtask.id"
|
|
v-model:employee-data="editModeEmployee"
|
|
:timeblocks="getEmployeeTimeblocks(subtask.employee_id)"
|
|
:all-timeblocks="timeblocks"
|
|
:deleted-timeblock-ids="deletedTimeblockIds"
|
|
:custom-filter="customFilter"
|
|
@update:timeblocks="
|
|
updateEmployeeTimeblocks($event, subtask.employee_id)
|
|
"
|
|
@clear-employee="handleRemoveEmployee"
|
|
@add-timeblock="
|
|
(afterIndex) => addTimeblock(subtask.employee_id, afterIndex)
|
|
"
|
|
/>
|
|
|
|
<!-- Create Mode: Multiple Employees -->
|
|
<template v-else>
|
|
<A-employee
|
|
v-for="(employee, index) in subtask.employees"
|
|
:key="`employee-${employee.id || index}`"
|
|
:ref="(el) => setEmployeeRef(el, index)"
|
|
v-model:employee-data="subtask.employees[index]"
|
|
:timeblocks="getEmployeeTimeblocks(employee.id)"
|
|
:all-timeblocks="timeblocks"
|
|
:deleted-timeblock-ids="deletedTimeblockIds"
|
|
:custom-filter="customFilter"
|
|
:required="index === 0"
|
|
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
|
|
@clear-employee="() => removeEmployeeAtIndex(index)"
|
|
@add-timeblock="
|
|
(afterIndex) => addTimeblock(employee.id, afterIndex)
|
|
"
|
|
/>
|
|
</template>
|
|
|
|
<div class="w-100 modal-input quill-editor-wrapper">
|
|
<QuillEditor
|
|
ref="description"
|
|
v-model="subtask.description"
|
|
placeholder="Popis"
|
|
theme="snow"
|
|
class="rich-text-editor"
|
|
@input="subtask.description = $event.srcElement.innerHTML"
|
|
/>
|
|
</div>
|
|
|
|
<Z-files-upload
|
|
class="mt-15 files"
|
|
:files="subtask.files"
|
|
:is-saving="isSaving"
|
|
@files-uploaded="filesUploaded"
|
|
/>
|
|
</v-card-actions>
|
|
|
|
<template v-if="!subtask.id">
|
|
<v-btn class="button primary" :disabled="isSaving" @click="save()"
|
|
>VYTVORIŤ</v-btn
|
|
>
|
|
|
|
<v-btn
|
|
class="button primary outline mt-2"
|
|
:disabled="isSaving"
|
|
@click="save(true)"
|
|
>ULOŽIŤ KONCEPT</v-btn
|
|
>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<template v-if="subtask.is_draft">
|
|
<v-btn class="button primary" :disabled="isSaving" @click="save()"
|
|
>ULOŽIŤ A PUBLIKOVAŤ</v-btn
|
|
>
|
|
|
|
<v-btn
|
|
class="button primary outline mt-2"
|
|
:disabled="isSaving"
|
|
@click="save(true)"
|
|
>ULOŽIŤ KONCEPT</v-btn
|
|
>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<v-btn class="button primary" :disabled="isSaving" @click="save()"
|
|
>UPRAVIŤ</v-btn
|
|
>
|
|
</template>
|
|
</template>
|
|
<a-resize-handle
|
|
:current-width="modalWidthPx"
|
|
:current-height="modalHeightPx"
|
|
@resize="handleModalResize"
|
|
@resize-end="handleResizeEnd"
|
|
/>
|
|
</v-card>
|
|
|
|
<v-card
|
|
v-else
|
|
height="100%"
|
|
class="d-flex flex-column scrolling"
|
|
style="position: relative"
|
|
>
|
|
<div class="d-flex justify-space-between align-center">
|
|
<div>
|
|
<p>{{ subtask._master_task.project_code }}</p>
|
|
<h1>{{ subtask._master_task.name }}</h1>
|
|
</div>
|
|
|
|
<div class="d-flex align-center">
|
|
<a-reset-size-button @reset="resetModalSize" />
|
|
<a-close-button @close="closeModal()" />
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="d-flex justify-space-between align-center modal-header border-bottom pb-4"
|
|
>
|
|
<h1>{{ subtask.title }}</h1>
|
|
<div class="d-flex justify-start">
|
|
<img
|
|
class="icon"
|
|
:src="require('@/plugins/app/_img/icons/pencil.svg')"
|
|
alt="pencil-icon"
|
|
@click.stop="toggleEditMode"
|
|
/>
|
|
<img
|
|
class="icon trash"
|
|
:src="require('@/plugins/app/_img/icons/bin.svg')"
|
|
alt="trash-icon"
|
|
@click.stop="openDeleteTaskModal(subtask)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<v-card-actions class="d-flex flex-column modal-content">
|
|
<div class="w-100">
|
|
<Z-task-readonly-info
|
|
:subtask="subtask"
|
|
@close-modal="closeModal"
|
|
@update-subtask="updateSubtask"
|
|
/>
|
|
|
|
<div class="d-flex justify-start align-center mt-8">
|
|
<v-btn
|
|
v-if="!subtask.is_finished"
|
|
class="done-btn"
|
|
@click.stop="showCompleteTaskModal()"
|
|
>Dokončiť</v-btn
|
|
>
|
|
<v-btn
|
|
v-else
|
|
class="button secondary"
|
|
@click.stop="showCompleteTaskModal()"
|
|
>Znovu otvoriť</v-btn
|
|
>
|
|
<v-btn class="button secondary" @click="closeModal()"
|
|
>Uložiť zmeny</v-btn
|
|
>
|
|
</div>
|
|
</div>
|
|
</v-card-actions>
|
|
<a-resize-handle
|
|
:current-width="modalWidthPx"
|
|
:current-height="modalHeightPx"
|
|
@resize="handleModalResize"
|
|
@resize-end="handleResizeEnd"
|
|
/>
|
|
</v-card>
|
|
</W-loadable>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script>
|
|
import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
|
|
import ZFilesUpload from '@/plugins/tm@_components/z-files-upload.vue';
|
|
import useVuelidate from '@vuelidate/core';
|
|
import { required } from '@vuelidate/validators';
|
|
import errorUtils from '@/plugins/lib@auth/_utils/error.utils';
|
|
import { QuillEditor } from '@vueup/vue-quill';
|
|
import SelectsMixin from '../master-task/_mixins/selects.mixin';
|
|
import tmUtils from '@/plugins/tm@_utils/tm.utils';
|
|
import moment from 'moment';
|
|
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
|
import AutocompleteMixin from '@/plugins/tm@_mixins/autocomplete.mixin';
|
|
|
|
import ZTaskReadonlyInfo from './_components/z-task-readonly-info.vue';
|
|
import AEmployee from './_components/employees/a-employee.vue';
|
|
import { mapGetters } from 'vuex';
|
|
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: {
|
|
ZTaskReadonlyInfo,
|
|
AEmployee,
|
|
ZFilesUpload,
|
|
ZDatepicker,
|
|
// ATaskTimeblocks,
|
|
QuillEditor,
|
|
AResizeHandle,
|
|
AResetSizeButton,
|
|
ACloseButton,
|
|
ZRemoveEmployeeModal,
|
|
ZRemoveTimeblockModal,
|
|
},
|
|
mixins: [SelectsMixin, AutocompleteMixin, modalMixin],
|
|
emits: ['close', 'refresh'],
|
|
|
|
data() {
|
|
return {
|
|
tmUtils,
|
|
editMode: false,
|
|
visible: false,
|
|
subtask: null,
|
|
isSaving: false,
|
|
errorUtils,
|
|
v$: useVuelidate(),
|
|
showDatePicker: false,
|
|
selectedNewDate: [],
|
|
timeblocks: [],
|
|
deletedTimeblockIds: [], // Track IDs of saved timeblocks that have been deleted
|
|
wasFormValidationTriggered: false,
|
|
modalName: 'z-task-modal',
|
|
employeeRefs: [],
|
|
pendingRemoveEmployeeId: undefined,
|
|
pendingRemoveEmployeeIndex: undefined,
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters('tm', ['mastertasks', 'enrichedEmployees']),
|
|
enrichedMastertasks() {
|
|
return this.mastertasks.map((mastertask) => ({
|
|
...mastertask,
|
|
_fullName: `${mastertask.name} [${mastertask.project_code}]`,
|
|
}));
|
|
},
|
|
editModeEmployee: {
|
|
get() {
|
|
return {
|
|
id: this.subtask?.employee_id || null,
|
|
duration: this.subtask?.duration || null,
|
|
};
|
|
},
|
|
set(value) {
|
|
if (this.subtask) {
|
|
this.subtask.employee_id = value.id;
|
|
this.subtask.duration = value.duration;
|
|
}
|
|
},
|
|
},
|
|
},
|
|
|
|
validations() {
|
|
return {
|
|
subtask: {
|
|
master_task_id: { required },
|
|
deadline: { required },
|
|
_deadline_time: { required },
|
|
},
|
|
};
|
|
},
|
|
|
|
watch: {
|
|
'subtask.employee_id'(value, oldValue) {
|
|
if (!value || !oldValue) return;
|
|
|
|
// In edit mode: reassign timeblocks to new employee
|
|
if (this.subtask.id) {
|
|
this.timeblocks = this.timeblocks.map((tb) => {
|
|
if (tb.employee_id === oldValue) {
|
|
return { ...tb, employee_id: value };
|
|
}
|
|
return tb;
|
|
});
|
|
} else {
|
|
// In create mode: remove old employee's timeblocks
|
|
this.timeblocks = this.timeblocks.filter(
|
|
(tb) => tb.employee_id !== oldValue,
|
|
);
|
|
}
|
|
|
|
this.subtask._employee = this.enrichedEmployees.find(
|
|
(employee) => employee.id == this.subtask.employee_id,
|
|
);
|
|
},
|
|
|
|
'subtask.employees': {
|
|
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({});
|
|
},
|
|
deep: true,
|
|
},
|
|
|
|
'subtask.master_task_id'(value) {
|
|
if (!value) return;
|
|
this.subtask._master_task = this.mastertasks.find(
|
|
(mastertask) => mastertask.id == this.subtask.master_task_id,
|
|
);
|
|
this.subtask.project_code =
|
|
this.subtask._master_task?.project_code || null;
|
|
},
|
|
|
|
editMode() {
|
|
this.$nextTick(() => {
|
|
this.$refs['description']?.setHTML(this.subtask.description || '');
|
|
});
|
|
},
|
|
|
|
subtask: {
|
|
handler(val) {
|
|
if (this.editMode && !this.subtask?.id) {
|
|
const employees = val?.employees?.filter(
|
|
(employee) => employee?.id || employee?.duration,
|
|
) || [{}];
|
|
|
|
const subtaskData = {
|
|
...val,
|
|
employees: employees && employees.length > 0 ? employees : [{}],
|
|
};
|
|
localStorage.setItem('subtaskData', JSON.stringify(subtaskData));
|
|
}
|
|
},
|
|
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) return;
|
|
|
|
if (!this.subtask.timeblocks) {
|
|
this.subtask.timeblocks = [];
|
|
}
|
|
if (!this.subtask.timesheets) {
|
|
this.subtask.timesheets = [];
|
|
}
|
|
if (!this.subtask.is_finished) {
|
|
this.subtask.is_finished = 0;
|
|
}
|
|
this.subtask.timeblocks.push(timeblock);
|
|
});
|
|
|
|
this.$events.on('on-subtask-edited', (subtask) => {
|
|
if (subtask?.id !== this.subtask?.id) return;
|
|
const data = JSON.parse(JSON.stringify(subtask));
|
|
|
|
const splittedDeadline = data.deadline?.split(' ') || [];
|
|
|
|
data.deadline = splittedDeadline[0];
|
|
data._deadline_time = splittedDeadline[1];
|
|
|
|
// Convert BE format timeblocks to internal format
|
|
const convertedTimeblocks = [];
|
|
if (data.timeblocks && data.timeblocks.length > 0) {
|
|
data.timeblocks.forEach((timeblock) => {
|
|
// If already in internal format, keep as is
|
|
if (timeblock.date) {
|
|
convertedTimeblocks.push({
|
|
...timeblock,
|
|
employee_id: data.employee_id, // Add employee_id
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Convert from BE format to internal format
|
|
if (timeblock.start && timeblock.end) {
|
|
const startMoment = moment(timeblock.start, 'YYYY-MM-DD HH:mm:ss');
|
|
const endMoment = moment(timeblock.end, 'YYYY-MM-DD HH:mm:ss');
|
|
convertedTimeblocks.push({
|
|
id: timeblock.id || null,
|
|
date: startMoment.toDate(),
|
|
starts_at_time: startMoment.format('HH:mm'),
|
|
ends_at_time: endMoment.format('HH:mm'),
|
|
is_finished: timeblock.is_finished || 0,
|
|
employee_id: data.employee_id, // Add employee_id
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// Store in component's timeblocks array
|
|
this.timeblocks = convertedTimeblocks;
|
|
|
|
this.subtask = this._enrichTask(data);
|
|
|
|
// TODO: Remove? riesil som neodstranovanie jedineho zanamu v trvani a odpracovanom case
|
|
this.$forceUpdate();
|
|
});
|
|
|
|
this.$events.on('on-subtask-deleted', (subtask) => {
|
|
if (subtask?.id == this.subtask?.id) this.closeModal();
|
|
});
|
|
},
|
|
|
|
methods: {
|
|
toggleEditMode() {
|
|
if (this.subtask.type == 'office365')
|
|
return this.$events.emit('show-unable-to-edit-office-task-modal', {
|
|
mode: 'edit',
|
|
});
|
|
|
|
this.editMode = !this.editMode;
|
|
},
|
|
|
|
// Set refs for employee components in create mode
|
|
setEmployeeRef(el, index) {
|
|
if (el) {
|
|
this.employeeRefs[index] = el;
|
|
}
|
|
},
|
|
|
|
openDatepicker(ref) {
|
|
this.$refs[ref].openMenu();
|
|
},
|
|
|
|
// Helper: Get employee by ID
|
|
getEmployeeById(employeeId) {
|
|
return this.enrichedEmployees.find((emp) => emp.id === employeeId);
|
|
},
|
|
|
|
// Helper: Get employee full name
|
|
getEmployeeFullName(employeeId) {
|
|
const employee = this.getEmployeeById(employeeId);
|
|
return employee ? `${employee.name} ${employee.surname}` : '';
|
|
},
|
|
|
|
// Helper: Get valid employees (with id and duration) in create mode
|
|
getValidEmployees() {
|
|
if (!this.subtask?.employees) return [];
|
|
return this.subtask.employees.filter((emp) => emp.id && emp.duration);
|
|
},
|
|
|
|
// Helper: Get timeblocks for specific employee
|
|
getEmployeeTimeblocks(employeeId) {
|
|
if (!this.timeblocks) return [];
|
|
return this.timeblocks.filter((tb) => tb.employee_id === employeeId);
|
|
},
|
|
|
|
updateEmployeeTimeblocks(updatedTimeblocks, employeeId) {
|
|
// Track deleted timeblocks (saved timeblocks that are no longer in the updated list)
|
|
const updatedIds = new Set(
|
|
updatedTimeblocks.filter((tb) => tb.id).map((tb) => tb.id),
|
|
);
|
|
const deletedIds = this.timeblocks
|
|
.filter(
|
|
(tb) =>
|
|
tb.employee_id === employeeId && tb.id && !updatedIds.has(tb.id),
|
|
)
|
|
.map((tb) => tb.id)
|
|
.filter((id) => !this.deletedTimeblockIds.includes(id));
|
|
|
|
this.deletedTimeblockIds.push(...deletedIds);
|
|
|
|
// Replace timeblocks for this employee
|
|
const otherEmployeeTimeblocks = this.timeblocks.filter(
|
|
(tb) => tb.employee_id !== employeeId,
|
|
);
|
|
const updatedWithEmployeeId = updatedTimeblocks.map((tb) => ({
|
|
...tb,
|
|
employee_id: employeeId,
|
|
}));
|
|
|
|
this.timeblocks = [...otherEmployeeTimeblocks, ...updatedWithEmployeeId];
|
|
},
|
|
|
|
updateTimeblock(val, index, employeeId) {
|
|
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
|
|
const timeblock = employeeTimeblocks[index];
|
|
|
|
if (!timeblock) return;
|
|
|
|
// Find the actual index in the full timeblocks array
|
|
const actualIndex = this.timeblocks.indexOf(timeblock);
|
|
|
|
if (actualIndex !== -1) {
|
|
this.timeblocks[actualIndex] = { ...val, employee_id: employeeId };
|
|
}
|
|
},
|
|
|
|
addTimeblock(employeeId, afterIndex = -1) {
|
|
const newTimeblock = {
|
|
date: new Date(), // Preset to current day
|
|
starts_at_time: null,
|
|
ends_at_time: null,
|
|
employee_id: employeeId,
|
|
};
|
|
|
|
// Get timeblocks for this employee
|
|
const employeeTimeblocks = this.timeblocks.filter(
|
|
(tb) => tb.employee_id === employeeId,
|
|
);
|
|
|
|
if (afterIndex === -1 || afterIndex >= employeeTimeblocks.length - 1) {
|
|
// Add at the end
|
|
this.timeblocks.push(newTimeblock);
|
|
} else {
|
|
// Find the position in the full timeblocks array
|
|
const timeblockToInsertAfter = employeeTimeblocks[afterIndex];
|
|
const insertPosition =
|
|
this.timeblocks.indexOf(timeblockToInsertAfter) + 1;
|
|
this.timeblocks.splice(insertPosition, 0, newTimeblock);
|
|
}
|
|
},
|
|
|
|
removeTimeblock(index, employeeId) {
|
|
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
|
|
const timeblock = employeeTimeblocks[index];
|
|
|
|
if (!timeblock) return;
|
|
|
|
// Find the actual index in the full timeblocks array
|
|
const actualIndex = this.timeblocks.indexOf(timeblock);
|
|
|
|
if (actualIndex !== -1) {
|
|
this.timeblocks.splice(actualIndex, 1);
|
|
}
|
|
},
|
|
|
|
// Handle remove employee - also removes their timeblocks
|
|
handleRemoveEmployee() {
|
|
this.pendingRemoveEmployeeId = this.subtask.employee_id;
|
|
this.$refs.removeEmployeeModal.showModal();
|
|
},
|
|
|
|
removeEmployeeAtIndex(index) {
|
|
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(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;
|
|
|
|
this.visible = true;
|
|
this.timeblocks = [];
|
|
this.deletedTimeblockIds = []; // Reset deleted timeblock IDs
|
|
this.employeeRefs = [];
|
|
this.$nextTick(() => {
|
|
this.v$.$reset();
|
|
|
|
// Reset after modal is fully rendered and v-dialog autofocus has been blocked
|
|
setTimeout(() => {
|
|
this.preventInputFocus = false;
|
|
}, 300);
|
|
});
|
|
|
|
try {
|
|
if (subtask?.id) {
|
|
const data = await this.$axios.get_auth_data(
|
|
`v1/subtasks/${subtask.id}`,
|
|
);
|
|
|
|
// Convert BE format timeblocks to internal format
|
|
const convertedTimeblocks = [];
|
|
if (data.timeblocks && data.timeblocks.length > 0) {
|
|
data.timeblocks.forEach((timeblock) => {
|
|
// If already in internal format, keep as is
|
|
if (timeblock.date) {
|
|
convertedTimeblocks.push({
|
|
...timeblock,
|
|
employee_id: data.employee_id, // Add employee_id for edit mode
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Convert from BE format to internal format
|
|
if (timeblock.start && timeblock.end) {
|
|
const startMoment = moment(
|
|
timeblock.start,
|
|
'YYYY-MM-DD HH:mm:ss',
|
|
);
|
|
const endMoment = moment(timeblock.end, 'YYYY-MM-DD HH:mm:ss');
|
|
convertedTimeblocks.push({
|
|
id: timeblock.id || null,
|
|
date: startMoment.toDate(),
|
|
starts_at_time: startMoment.format('HH:mm'),
|
|
ends_at_time: endMoment.format('HH:mm'),
|
|
is_finished: timeblock.is_finished || 0,
|
|
employee_id: data.employee_id, // Add employee_id for edit mode
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// Store timeblocks in the component's timeblocks array
|
|
this.timeblocks = convertedTimeblocks;
|
|
|
|
// Ensure at least one timeblock exists for edit mode
|
|
if (data.employee_id && this.timeblocks.length === 0) {
|
|
this.addTimeblock(data.employee_id);
|
|
}
|
|
|
|
if (!data.deadline) {
|
|
this.editMode = editMode || !data.id;
|
|
return (this.subtask = this._enrichTask(data));
|
|
}
|
|
|
|
const splittedDeadline = data.deadline.split(' ');
|
|
data.deadline = splittedDeadline[0];
|
|
data._deadline_time = splittedDeadline[1];
|
|
|
|
this.subtask = this._enrichTask(data);
|
|
} else {
|
|
const savedSubtask = localStorage.getItem('subtaskData');
|
|
const savedTimeblocks = localStorage.getItem('subtaskTimeblocks');
|
|
|
|
try {
|
|
if (savedSubtask) {
|
|
const parsedSubtask = JSON.parse(savedSubtask);
|
|
this.subtask = parsedSubtask;
|
|
// If the parsed result is not an object, reset to 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 || '',
|
|
);
|
|
});
|
|
} else {
|
|
throw new Error('subtaskData is null or undefined');
|
|
}
|
|
} catch (error) {
|
|
this.subtask = {
|
|
master_task: mastertask,
|
|
employees: [{}],
|
|
_deadline_time: '17:00',
|
|
};
|
|
}
|
|
|
|
if (
|
|
subtask?.deadline &&
|
|
subtask._deadline_time &&
|
|
subtask.employee_id
|
|
) {
|
|
const employee = this.enrichedEmployees.find(
|
|
(employee) => employee.id == subtask.employee_id,
|
|
);
|
|
this.subtask.employees = [employee];
|
|
this.subtask.deadline = subtask.deadline;
|
|
this.subtask._deadline_time = subtask._deadline_time;
|
|
}
|
|
|
|
if (subtask?.master_task_id && subtask?.project_code) {
|
|
this.subtask.master_task_id = subtask.master_task_id;
|
|
this.subtask.project_code = subtask.project_code;
|
|
}
|
|
}
|
|
this.editMode = editMode || !this.subtask.id;
|
|
} catch (error) {
|
|
this.$toast.error(this.$parseError(error));
|
|
}
|
|
},
|
|
|
|
deleteTimeblock(i) {
|
|
this.subtask.timeblocks.splice(i, 1);
|
|
},
|
|
|
|
closeModal() {
|
|
this.visible = false;
|
|
this.$router.replace({ name: this.$route.name });
|
|
},
|
|
|
|
async validate(concept = false) {
|
|
if (concept === false) {
|
|
const employeesValid = await this._validateEmployees();
|
|
const timeblocksValid = await this._validateTimeblocks();
|
|
|
|
if (!(await this.v$.$validate())) return;
|
|
if (!employeesValid || !timeblocksValid) return;
|
|
|
|
return true;
|
|
} else {
|
|
this.v$.subtask.master_task_id.$touch();
|
|
|
|
return this.v$.subtask.master_task_id.$errors.length === 0;
|
|
}
|
|
},
|
|
|
|
async save(concept = false) {
|
|
this.v$.$reset();
|
|
|
|
this.wasFormValidationTriggered = true;
|
|
if (!(await this.validate(concept))) return;
|
|
|
|
this.subtask.description = this.$refs['description'].getHTML();
|
|
this._mergeDeadlineTimeDate();
|
|
|
|
try {
|
|
this.isSaving = true;
|
|
|
|
// Concept tasks may not have a deadline time set
|
|
this.subtask.deadline = this.subtask.deadline
|
|
? moment(this.subtask.deadline).format('YYYY-MM-DD HH:mm:SS')
|
|
: null;
|
|
|
|
if (this.subtask?.id) {
|
|
const timesheets = this.subtask.timesheets.map((timesheet) => {
|
|
delete timesheet.employee_name;
|
|
return timesheet;
|
|
});
|
|
|
|
// 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 complete timeblocks for this employee
|
|
.map((timeblock) => {
|
|
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
|
|
return {
|
|
id: timeblock.id || null,
|
|
start: `${dateStr} ${timeblock.starts_at_time}:00`,
|
|
end: `${dateStr} ${timeblock.ends_at_time}:00`,
|
|
is_finished: timeblock.is_finished || 0,
|
|
};
|
|
});
|
|
|
|
const payload = { ...this.subtask, timesheets, timeblocks };
|
|
|
|
if (concept) {
|
|
payload.is_draft = 1;
|
|
} else {
|
|
payload.is_draft = 0;
|
|
}
|
|
|
|
const { subtask } = await this.$axios.patch_auth_form(
|
|
`v1/subtasks/${this.subtask.id}`,
|
|
payload,
|
|
);
|
|
localStorage.removeItem('subtaskData');
|
|
localStorage.removeItem('subtaskTimeblocks');
|
|
|
|
this.$store.commit('tm/update_subtask', subtask);
|
|
this.$events.emit('on-subtask-edited', this.subtask);
|
|
} else {
|
|
// Filter out employees that don't have both id and duration
|
|
const validEmployees = this.subtask.employees.filter(
|
|
(employee) => employee.id && employee.duration,
|
|
);
|
|
|
|
// 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.starts_at_time &&
|
|
timeblock.ends_at_time &&
|
|
timeblock.employee_id === employee.id,
|
|
)
|
|
.map((timeblock) => {
|
|
return {
|
|
start: `${moment(timeblock.date).format('YYYY-MM-DD')} ${
|
|
timeblock.starts_at_time
|
|
}:00`,
|
|
end: `${moment(timeblock.date).format('YYYY-MM-DD')} ${
|
|
timeblock.ends_at_time
|
|
}:00`,
|
|
id: null,
|
|
};
|
|
});
|
|
});
|
|
|
|
const subtaskCopy = {
|
|
...this.subtask,
|
|
employees: validEmployees,
|
|
};
|
|
if (concept) {
|
|
subtaskCopy.is_draft = true;
|
|
subtaskCopy.employees =
|
|
subtaskCopy.employees?.length > 0
|
|
? subtaskCopy.employees
|
|
: [{ id: this.$store.getters['auth/user']?.id, duration: 0 }];
|
|
}
|
|
delete subtaskCopy._master_task;
|
|
const { subtasks } = await this.$axios.post_auth(
|
|
`v1/mastertasks/${this.subtask.master_task_id}/subtasks`,
|
|
subtaskCopy,
|
|
);
|
|
localStorage.removeItem('subtaskData');
|
|
localStorage.removeItem('subtaskTimeblocks');
|
|
this.subtask.id = subtasks[0].id;
|
|
|
|
subtasks.forEach((subtask) => {
|
|
this.$store.commit('tm/update_subtask', subtask);
|
|
this.$events.emit('on-subtask-edited', this.subtask);
|
|
});
|
|
this.$events.emit('task-created');
|
|
this.$events.emit('close-both-modals');
|
|
}
|
|
this.closeModal();
|
|
} catch (error) {
|
|
this.$toast.error(this.$parseError(error));
|
|
} finally {
|
|
this.isSaving = false;
|
|
}
|
|
},
|
|
|
|
openDeleteTaskModal(subtask) {
|
|
if (subtask.type == 'office365')
|
|
return this.$events.emit('show-unable-to-edit-office-task-modal', {
|
|
mode: 'delete',
|
|
});
|
|
|
|
this.$events.emit('show-delete-task-modal', { subtask });
|
|
},
|
|
|
|
showCompleteTaskModal() {
|
|
this.$events.emit('show-complete-task-modal', { subtask: this.subtask });
|
|
if (this.subtask.deadline)
|
|
this.subtask.deadline = tmUtils.formatDateTimeForBackend(
|
|
this.subtask.deadline,
|
|
);
|
|
},
|
|
|
|
filesUploaded(files) {
|
|
this.subtask.files = files;
|
|
},
|
|
|
|
updateSubtask(subtask) {
|
|
this.subtask = subtask;
|
|
},
|
|
|
|
_mergeDeadlineTimeDate() {
|
|
// Override for concept tasks which may not have a deadline time set (check for invalid date)
|
|
if (isNaN(new Date(this.subtask.deadline).getTime())) {
|
|
this.subtask.deadline = null;
|
|
} else {
|
|
const date = new Date(this.subtask.deadline);
|
|
const timeParts = this.subtask._deadline_time.split(':');
|
|
|
|
date.setHours(parseInt(timeParts[0]));
|
|
date.setMinutes(parseInt(timeParts[1]));
|
|
|
|
this.subtask.deadline = date;
|
|
}
|
|
},
|
|
|
|
async _validateEmployees() {
|
|
if (this.subtask.id) {
|
|
// For existing subtasks, at least one employee is required
|
|
const hasEmployee = !!(
|
|
this.subtask.employee_id && this.subtask.duration
|
|
);
|
|
return hasEmployee;
|
|
} else {
|
|
let valid = true;
|
|
|
|
// 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 employees that have any data (partial or complete)
|
|
if (employeeRef && typeof employeeRef.validate === 'function') {
|
|
if (!(await employeeRef.validate())) {
|
|
valid = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return valid;
|
|
}
|
|
},
|
|
|
|
async _validateTimeblocks() {
|
|
// 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) {
|
|
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;
|
|
}
|
|
|
|
// Validate that end time is after start time
|
|
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
|
|
const start = moment(
|
|
`${dateStr} ${timeblock.starts_at_time}`,
|
|
'YYYY-MM-DD HH:mm',
|
|
);
|
|
const end = moment(
|
|
`${dateStr} ${timeblock.ends_at_time}`,
|
|
'YYYY-MM-DD HH:mm',
|
|
);
|
|
|
|
if (end.isSameOrBefore(start)) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return valid;
|
|
},
|
|
|
|
_enrichTask(subtask) {
|
|
return {
|
|
...subtask,
|
|
_created_by: this.enrichedEmployees.find(
|
|
(employee) => employee.id == subtask.created_by,
|
|
),
|
|
_employee: this.enrichedEmployees.find(
|
|
(employee) => employee.id == subtask.employee_id,
|
|
),
|
|
_master_task: this.mastertasks.find(
|
|
(mastertask) => mastertask.id == subtask.master_task_id,
|
|
),
|
|
};
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="sass" scoped>
|
|
.w-full
|
|
width: 100%
|
|
|
|
.date-wrap
|
|
display: flex
|
|
flex-flow: row
|
|
width: 100%
|
|
gap: 20px
|
|
margin-bottom: 20px
|
|
|
|
.dp__main
|
|
width: calc(50% - 10px) !important
|
|
|
|
.scrolling
|
|
overflow: hidden !important
|
|
|
|
.border-bottom
|
|
border-bottom: 1px solid rgba(0,0,0,.1)
|
|
|
|
.splitted-inputs
|
|
display: flex
|
|
justify-content: space-between
|
|
gap: 16px
|
|
|
|
.left
|
|
flex: 1
|
|
|
|
.right ::v-deep
|
|
.v-field__field
|
|
height: 56px !important
|
|
|
|
.done-btn
|
|
background: #7f39fb !important
|
|
border-radius: 4px !important
|
|
color: #fff !important
|
|
height: 50px
|
|
width: 203px
|
|
|
|
@media screen and (max-width: 522px)
|
|
.files
|
|
margin-top: 104px !important
|
|
|
|
.quill-editor-wrapper
|
|
:deep(.ql-container)
|
|
min-height: 200px
|
|
max-height: 500px
|
|
overflow-y: auto
|
|
|
|
:deep(.ql-editor)
|
|
min-height: 200px
|
|
max-height: 500px
|
|
|
|
// Mobile fullscreen fix - remove padding
|
|
:deep(.v-overlay__content)
|
|
@media (max-width: 768px)
|
|
max-height: 100% !important
|
|
width: 100% !important
|
|
max-width: 100% !important
|
|
margin: 0 !important
|
|
</style>
|