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

@@ -10,7 +10,8 @@
item-value="id" item-value="id"
variant="solo" variant="solo"
class="employee-select__select-field" class="employee-select__select-field"
:class="{ error: v$.$errors.length && v$.id.$errors.length }" :class="{ error: v$.id.$error }"
:error-messages="v$.id.$errors.map((e: any) => String(e.$message))"
@update:model-value="handleUpdateEmployee" @update:model-value="handleUpdateEmployee"
/> />
@@ -20,7 +21,8 @@
variant="solo" variant="solo"
type="number" type="number"
label="Časový odhad" label="Časový odhad"
:class="{ error: v$.$errors.length && v$.duration.$errors.length }" :class="{ error: v$.duration.$error }"
:error-messages="v$.duration.$errors.map((e: any) => String(e.$message))"
append-inner-icon="mdi-clock-outline" append-inner-icon="mdi-clock-outline"
@update:model-value="handleUpdateDuration" @update:model-value="handleUpdateDuration"
/> />
@@ -36,15 +38,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, toRefs } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators'; import { helpers, required } from '@vuelidate/validators';
import type { EmployeeData, CustomFilterFunction } from './types'; import type { EmployeeData, CustomFilterFunction } from './types';
interface Props { interface Props {
modelValue: EmployeeData; modelValue: EmployeeData;
customFilter?: CustomFilterFunction; customFilter?: CustomFilterFunction;
required?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -60,34 +63,67 @@ const store = useStore();
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']); const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
// Check if employee has partial data (one field filled but not both) // Make modelValue reactive for Vuelidate
const hasPartialData = computed(() => { const { modelValue } = toRefs(props);
const { id, duration } = props.modelValue;
return !!(id || duration); // Has data if either field is filled
});
// Vuelidate rules - only validate if employee has partial data // Custom validator: if either field is filled, both are required
const rules = { const requiredWhenPaired = (fieldName: 'id' | 'duration') => {
id: { required: requiredIf(hasPartialData) }, return (_value: any) => {
duration: { required: requiredIf(hasPartialData) }, const { id, duration } = modelValue.value;
const hasAnyData = !!(id || duration);
// If no data at all, this is valid (both fields optional)
if (!hasAnyData) {
return true;
}
// If there's any data, this field must be filled
if (fieldName === 'id') {
return !!id;
} else {
return duration !== null && duration !== undefined;
}
};
}; };
const v$ = useVuelidate(rules, props.modelValue); // Vuelidate rules - only validate if employee has partial data
const rules = computed(() => {
return props.required
? {
id: { required },
duration: { required },
}
: {
id: {
requiredWhenPaired: helpers.withMessage(
'Vyberte zamestnanca',
requiredWhenPaired('id'),
),
},
duration: {
requiredWhenPaired: helpers.withMessage(
'Zadajte časový odhad',
requiredWhenPaired('duration'),
),
},
};
});
const v$ = useVuelidate(rules.value, modelValue, { $autoDirty: false });
const handleUpdateEmployee = (value: number) => { const handleUpdateEmployee = (value: number) => {
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
id: value, id: value,
}); });
v$.value.$reset();
}; };
const handleUpdateDuration = (value: string) => { const handleUpdateDuration = (value: string) => {
const parsedValue = parseFloat(value);
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
duration: parseFloat(value) || null, duration: isNaN(parsedValue) ? null : parsedValue,
}); });
v$.value.$reset();
}; };
const handleClearEmployee = () => { const handleClearEmployee = () => {
@@ -95,7 +131,19 @@ const handleClearEmployee = () => {
}; };
// Expose validate method // Expose validate method
const validate = async () => { const validate = async (required = false) => {
// If required, check that both fields are set
if (required) {
const { id, duration } = modelValue.value;
if (!id || duration === null || duration === undefined) {
// Explicitly mark fields as touched to show validation errors
v$.value.$touch();
return false;
}
}
// Explicitly mark fields as touched to show validation errors
v$.value.$touch();
const isValid = await v$.value.$validate(); const isValid = await v$.value.$validate();
return isValid; return isValid;
}; };
@@ -74,8 +74,11 @@
@input="$emit('updateEndTime', $event)" @input="$emit('updateEndTime', $event)"
/> />
</div> </div>
<div v-if="!isTimeRangeValid" class="validation-error"> <div
Koniec musí byť po začiatku v-if="!isTimeRangeValid || endTimeErrorMessage"
class="validation-error"
>
{{ endTimeErrorMessage || 'Koniec musí byť po začiatku' }}
</div> </div>
<div class="actions"> <div class="actions">
@@ -111,9 +114,12 @@ interface Props {
hasValidationErrors: boolean; hasValidationErrors: boolean;
hasStartTimeError: boolean; hasStartTimeError: boolean;
hasEndTimeError: boolean; hasEndTimeError: boolean;
endTimeErrorMessage?: string;
} }
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
endTimeErrorMessage: '',
});
defineEmits<{ defineEmits<{
(e: 'previousDay'): void; (e: 'previousDay'): void;
@@ -7,9 +7,10 @@
:is-editable="isEditable" :is-editable="isEditable"
:is-time-range-valid="isTimeRangeValid" :is-time-range-valid="isTimeRangeValid"
:collision="collision" :collision="collision"
:has-validation-errors="v$.$errors.length > 0" :has-validation-errors="v$.$error && v$.$anyDirty"
:has-start-time-error="v$.starts_at_time.$errors.length > 0" :has-start-time-error="hasStartTimeError"
:has-end-time-error="v$.ends_at_time.$errors.length > 0" :has-end-time-error="hasEndTimeError"
:end-time-error-message="endTimeErrorMessage"
@previous-day="previousDay" @previous-day="previousDay"
@next-day="nextDay" @next-day="nextDay"
@update-date="updateDate" @update-date="updateDate"
@@ -41,7 +42,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick } from 'vue'; import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators'; import { helpers } from '@vuelidate/validators';
import { useEmployeeTimeline } from '@/plugins/tm@_utils/useEmployeeTimeline'; import { useEmployeeTimeline } from '@/plugins/tm@_utils/useEmployeeTimeline';
import { timeblockToDateRange } from '@/plugins/tm@_utils/timeblock.utils'; import { timeblockToDateRange } from '@/plugins/tm@_utils/timeblock.utils';
import AEmployeeTimeblockHeader from './a-employee-timeblock-header.vue'; import AEmployeeTimeblockHeader from './a-employee-timeblock-header.vue';
@@ -111,27 +112,90 @@ const hourWidth = computed(() =>
isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH, isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH,
); );
// Check if timeblock has partial data (any field filled) // Custom validator for time range
const hasPartialData = computed(() => { const timeRangeValid = (value: any, siblings: any) => {
return !!( if (!siblings.starts_at_time || !siblings.ends_at_time || !siblings.date) {
props.modelValue.date || return true;
props.modelValue.starts_at_time || }
props.modelValue.ends_at_time const { start, end } = timeblockToDateRange(siblings);
); if (!start || !end) return true;
}); return end > start;
};
// Vuelidate rules - only validate if timeblock has partial data // Custom validator: field is required when start OR end time is filled
// Uses siblings to check the current state of all fields
const requiredWhenHasTime = (value: any, siblings: any) => {
// Check if timeblock has any TIME data (start or end time)
const hasStart = !!siblings.starts_at_time;
const hasEnd = !!siblings.ends_at_time;
const hasAnyTimeData = hasStart || hasEnd;
// If no time data, no validation needed
if (!hasAnyTimeData) return true;
// When time data exists, field must have value (not null, undefined, or empty string)
return value !== null && value !== undefined && value !== '';
};
// Vuelidate rules - if start OR end is filled, all three fields are required
const rules = { const rules = {
date: {}, date: {
requiredWhenHasTime: helpers.withMessage(
'Vyberte dátum',
requiredWhenHasTime,
),
},
starts_at_time: { starts_at_time: {
required: requiredIf(hasPartialData), requiredWhenHasTime: helpers.withMessage(
'Zadajte začiatok',
requiredWhenHasTime,
),
}, },
ends_at_time: { ends_at_time: {
required: requiredIf(hasPartialData), requiredWhenHasTime: helpers.withMessage(
'Zadajte koniec',
requiredWhenHasTime,
),
timeRangeValid: helpers.withMessage(
'Koniec musí byť po začiatku',
timeRangeValid,
),
}, },
}; };
const v$ = useVuelidate(rules, props.modelValue); const v$ = useVuelidate(rules, props.modelValue, { $autoDirty: true });
// Error visibility logic: show errors if field is dirty OR if the opposite time field is filled
const hasStartTimeError = computed(() => {
// Show error if field is dirty and has error
if (v$.value.starts_at_time.$dirty && v$.value.starts_at_time.$error) {
return true;
}
// OR if end time is filled but start is not
if (props.modelValue.ends_at_time && !props.modelValue.starts_at_time) {
return v$.value.starts_at_time.$error;
}
return false;
});
const hasEndTimeError = computed(() => {
// Show error if field is dirty and has error
if (v$.value.ends_at_time.$dirty && v$.value.ends_at_time.$error) {
return true;
}
// OR if start time is filled but end is not
if (props.modelValue.starts_at_time && !props.modelValue.ends_at_time) {
return v$.value.ends_at_time.$error;
}
return false;
});
const endTimeErrorMessage = computed(() => {
if (hasEndTimeError.value) {
return v$.value.ends_at_time.$errors.map((e) => e.$message).join(', ');
}
return '';
});
// Reactive refs for composable // Reactive refs for composable
const employeeRef = computed(() => props.employee); const employeeRef = computed(() => props.employee);
@@ -176,9 +240,13 @@ const unsavedTimeblocksForSameDate = computed(() => {
// Exclude the current timeblock // Exclude the current timeblock
if (index === props.currentIndex) return false; if (index === props.currentIndex) return false;
// Only include timeblocks without IDs (unsaved) or with the same date // Only include timeblocks WITHOUT IDs (unsaved)
if (tb.id) return false;
// Must have a date
if (!tb.date) return false; if (!tb.date) return false;
// Must be the same date
const tbDate = moment(tb.date).format('YYYY-MM-DD'); const tbDate = moment(tb.date).format('YYYY-MM-DD');
return tbDate === currentDate; return tbDate === currentDate;
}) })
@@ -231,16 +299,13 @@ watch(
); );
// Watch for timeline ref to become available and center it // Watch for timeline ref to become available and center it
watch( watch(timelineRef, (newRef) => {
timelineRef, if (newRef?.timelineScroll) {
(newRef) => { nextTick(() => {
if (newRef?.timelineScroll) { centerTimeline();
nextTick(() => { });
centerTimeline(); }
}); });
}
},
);
// Date navigation // Date navigation
const previousDay = () => { const previousDay = () => {
@@ -267,7 +332,6 @@ const nextDay = () => {
// Update handlers // Update handlers
const updateDate = (date: Date | null) => { const updateDate = (date: Date | null) => {
emit('update:modelValue', { ...props.modelValue, date }); emit('update:modelValue', { ...props.modelValue, date });
v$.value.$reset();
}; };
const updateStartTime = (event: Event) => { const updateStartTime = (event: Event) => {
@@ -276,7 +340,6 @@ const updateStartTime = (event: Event) => {
...props.modelValue, ...props.modelValue,
starts_at_time: target.value, starts_at_time: target.value,
}); });
v$.value.$reset();
}; };
const updateEndTime = (event: Event) => { const updateEndTime = (event: Event) => {
@@ -285,11 +348,11 @@ const updateEndTime = (event: Event) => {
...props.modelValue, ...props.modelValue,
ends_at_time: target.value, ends_at_time: target.value,
}); });
v$.value.$reset();
}; };
// Expose validate method // Expose validate method
const validate = async () => { const validate = async () => {
v$.value.$touch();
const isValid = await v$.value.$validate(); const isValid = await v$.value.$validate();
return isValid && isTimeRangeValid.value; return isValid && isTimeRangeValid.value;
}; };
@@ -4,6 +4,7 @@
ref="employeeSelectRef" ref="employeeSelectRef"
v-model="employeeDataLocal" v-model="employeeDataLocal"
:custom-filter="customFilter" :custom-filter="customFilter"
:required="required"
@clear-employee="handleClearEmployee" @clear-employee="handleClearEmployee"
/> />
@@ -28,7 +29,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'; import {
computed,
ref,
watch,
nextTick,
onMounted,
getCurrentInstance,
} from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import AEmployeeSelect from './a-employee-select.vue'; import AEmployeeSelect from './a-employee-select.vue';
import AEmployeeTimeblock from './a-employee-timeblock.vue'; import AEmployeeTimeblock from './a-employee-timeblock.vue';
@@ -43,12 +51,14 @@ interface Props {
timeblocks?: TimeblockData[]; timeblocks?: TimeblockData[];
customFilter?: CustomFilterFunction; customFilter?: CustomFilterFunction;
hasBackground?: boolean; hasBackground?: boolean;
required?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
timeblocks: () => [], timeblocks: () => [],
customFilter: undefined, customFilter: undefined,
hasBackground: true, hasBackground: true,
required: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -87,17 +97,28 @@ const selectedEmployee = computed(() => {
// Watch for when employee is selected and ensure at least one timeblock exists // Watch for when employee is selected and ensure at least one timeblock exists
watch( watch(
() => [props.employeeData.id, props.employeeData.duration], () => props.employeeData.id,
([newId, newDuration]) => { (newId, oldId) => {
// If employee has both id and duration and there are no timeblocks, request one to be added // If employee just got selected (id changed from null/undefined to a value)
if (newId && newDuration && props.timeblocks.length === 0) { // and there are no timeblocks for this employee, request one to be added
if (newId && !oldId && props.timeblocks.length === 0) {
nextTick(() => { nextTick(() => {
emit('addTimeblock', -1); emit('addTimeblock', -1);
}); });
} }
}, },
{ flush: 'post' }, // Ensure watch runs after all component updates
); );
// On mount, check if employee has ID but no timeblocks
onMounted(() => {
if (props.employeeData.id && props.timeblocks.length === 0) {
nextTick(() => {
emit('addTimeblock', -1);
});
}
});
const handleClearEmployee = () => { const handleClearEmployee = () => {
emit('clearEmployee'); emit('clearEmployee');
}; };
@@ -121,7 +142,6 @@ const handleAddTimeblock = (afterIndex: number) => {
// Expose validate method that validates all child components // Expose validate method that validates all child components
const validate = async () => { const validate = async () => {
let isValid = true; let isValid = true;
// Validate employee select // Validate employee select
if (employeeSelectRef.value) { if (employeeSelectRef.value) {
const selectValid = await employeeSelectRef.value.validate(); const selectValid = await employeeSelectRef.value.validate();
@@ -0,0 +1,76 @@
<template>
<v-dialog
v-model="visible"
:width="modalWidth"
:height="modalHeight"
persistent
:retain-focus="false"
>
<v-card style="position: relative">
<div class="d-flex justify-space-between align-center">
<h1>
Naozaj chcete odstrániť tohto zamestnanca a všetky jeho timeblocks?
</h1>
<div class="d-flex align-center">
<a-reset-size-button @reset="resetModalSize" />
<a-close-button class="mb-15" @close="closeModal()" />
</div>
</div>
<v-card-actions class="mt-10 d-flex justify-start align-center mobile-view">
<v-btn class="button primary half" variant="flat" @click="confirm()"
>ÁNO</v-btn
>
<v-btn class="button primary half outline" @click.stop="closeModal()"
>ZRUŠIŤ</v-btn
>
</v-card-actions>
<a-resize-handle
:current-width="modalWidthPx"
:current-height="modalHeightPx"
@resize="handleModalResize"
@resize-end="handleResizeEnd"
/>
</v-card>
</v-dialog>
</template>
<script>
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';
export default {
components: {
AResizeHandle,
AResetSizeButton,
ACloseButton,
},
mixins: [modalMixin],
emits: ['confirm'],
data() {
return {
visible: false,
modalName: 'z-remove-employee-modal',
};
},
methods: {
showModal() {
this.visible = true;
},
closeModal() {
this.visible = false;
},
confirm() {
this.$emit('confirm');
this.closeModal();
},
},
};
</script>
@@ -0,0 +1,78 @@
<template>
<v-dialog
v-model="visible"
:width="modalWidth"
:height="modalHeight"
persistent
:retain-focus="false"
>
<v-card style="position: relative">
<div class="d-flex justify-space-between align-center">
<h1>Naozaj chcete odstrániť tento timeblock?</h1>
<div class="d-flex align-center">
<a-reset-size-button @reset="resetModalSize" />
<a-close-button class="mb-15" @close="closeModal()" />
</div>
</div>
<v-card-actions class="mt-10 d-flex justify-start align-center mobile-view">
<v-btn class="button primary half" variant="flat" @click="confirm()"
>ÁNO</v-btn
>
<v-btn class="button primary half outline" @click.stop="closeModal()"
>ZRUŠIŤ</v-btn
>
</v-card-actions>
<a-resize-handle
:current-width="modalWidthPx"
:current-height="modalHeightPx"
@resize="handleModalResize"
@resize-end="handleResizeEnd"
/>
</v-card>
</v-dialog>
</template>
<script>
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';
export default {
components: {
AResizeHandle,
AResetSizeButton,
ACloseButton,
},
mixins: [modalMixin],
data() {
return {
visible: false,
modalName: 'z-remove-timeblock-modal',
onConfirmCallback: null,
};
},
methods: {
showModal({ onConfirm }) {
this.onConfirmCallback = onConfirm;
this.visible = true;
},
closeModal() {
this.visible = false;
this.onConfirmCallback = null;
},
confirm() {
if (this.onConfirmCallback) {
this.onConfirmCallback();
}
this.closeModal();
},
},
};
</script>
+119 -84
View File
@@ -1,4 +1,10 @@
<template> <template>
<z-remove-employee-modal
ref="removeEmployeeModal"
@confirm="confirmRemoveEmployee"
/>
<z-remove-timeblock-modal ref="removeTimeblockModal" />
<v-dialog <v-dialog
v-model="visible" v-model="visible"
:width="modalWidth" :width="modalWidth"
@@ -113,6 +119,7 @@
v-model:employee-data="subtask.employees[index]" v-model:employee-data="subtask.employees[index]"
:timeblocks="getEmployeeTimeblocks(employee.id)" :timeblocks="getEmployeeTimeblocks(employee.id)"
:custom-filter="customFilter" :custom-filter="customFilter"
:required="index === 0"
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)" @update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
@clear-employee="() => removeEmployeeAtIndex(index)" @clear-employee="() => removeEmployeeAtIndex(index)"
@add-timeblock=" @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 AResizeHandle from '@/plugins/tm@modal/_components/a-resize-handle.vue';
import AResetSizeButton from '@/plugins/tm@modal/_components/a-reset-size-button.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 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 { export default {
components: { components: {
@@ -288,6 +297,8 @@ export default {
AResizeHandle, AResizeHandle,
AResetSizeButton, AResetSizeButton,
ACloseButton, ACloseButton,
ZRemoveEmployeeModal,
ZRemoveTimeblockModal,
}, },
mixins: [SelectsMixin, AutocompleteMixin, modalMixin], mixins: [SelectsMixin, AutocompleteMixin, modalMixin],
emits: ['close', 'refresh'], emits: ['close', 'refresh'],
@@ -307,6 +318,8 @@ export default {
wasFormValidationTriggered: false, wasFormValidationTriggered: false,
modalName: 'z-task-modal', modalName: 'z-task-modal',
employeeRefs: [], employeeRefs: [],
pendingRemoveEmployeeId: undefined,
pendingRemoveEmployeeIndex: undefined,
}; };
}, },
@@ -355,34 +368,13 @@ export default {
}, },
'subtask.employees': { 'subtask.employees': {
handler(newEmployees, oldEmployees) { handler() {
if (!this.subtask?.employees) return; if (!this.subtask?.employees) return;
const lastEmployee = const lastEmployee =
this.subtask.employees[this.subtask.employees.length - 1]; this.subtask.employees[this.subtask.employees.length - 1];
if (lastEmployee?.id || lastEmployee?.duration) if (lastEmployee?.id || lastEmployee?.duration)
this.subtask.employees.push({}); 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, deep: true,
}, },
@@ -418,9 +410,24 @@ export default {
}, },
deep: true, 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() { mounted() {
// Handle remove timeblock modal
this.$events.on('show-remove-timeblock-modal', ({ onConfirm }) => {
this.$refs.removeTimeblockModal.showModal({ onConfirm });
});
this.$events.on('add-timeblock', (timeblock) => { this.$events.on('add-timeblock', (timeblock) => {
if (!this.subtask?.timeblocks) { if (!this.subtask?.timeblocks) {
this.subtask.timeblocks = []; this.subtask.timeblocks = [];
@@ -597,46 +604,51 @@ export default {
// Handle remove employee - also removes their timeblocks // Handle remove employee - also removes their timeblocks
handleRemoveEmployee() { handleRemoveEmployee() {
if (this.subtask.employee_id) { this.pendingRemoveEmployeeId = this.subtask.employee_id;
// Remove all timeblocks for this employee this.$refs.removeEmployeeModal.showModal();
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);
}
}, },
removeEmployeeAtIndex(index) { removeEmployeeAtIndex(index) {
const employee = this.subtask.employees[index]; this.pendingRemoveEmployeeIndex = index;
if (employee?.id) { this.$refs.removeEmployeeModal.showModal();
// Remove all timeblocks for this employee },
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employee.id, 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 // Remove employee from array
this.subtask.employees.splice(index, 1); this.subtask.employees.splice(this.pendingRemoveEmployeeIndex, 1);
this.pendingRemoveEmployeeIndex = undefined;
if (this.wasFormValidationTriggered) {
setTimeout(async () => {
this.validateAllFields();
}, 100);
} }
}, },
async showModal({ subtask, editMode = false, mastertask = {} }) { async showModal({ subtask, editMode = false, mastertask = {} }) {
this.isSaving = false; this.isSaving = false;
this.subtask = null; this.subtask = null;
this.wasFormValidationTriggered = false; // Reset validation trigger
// CRITICAL: Prevent v-dialog autofocus BEFORE modal opens // CRITICAL: Prevent v-dialog autofocus BEFORE modal opens
this.preventInputFocus = true; this.preventInputFocus = true;
@@ -710,6 +722,8 @@ export default {
this.subtask = this._enrichTask(data); this.subtask = this._enrichTask(data);
} else { } else {
const savedSubtask = localStorage.getItem('subtaskData'); const savedSubtask = localStorage.getItem('subtaskData');
const savedTimeblocks = localStorage.getItem('subtaskTimeblocks');
try { try {
if (savedSubtask) { if (savedSubtask) {
const parsedSubtask = JSON.parse(savedSubtask); const parsedSubtask = JSON.parse(savedSubtask);
@@ -718,6 +732,23 @@ export default {
if (typeof this.subtask !== 'object' || this.subtask === null) { if (typeof this.subtask !== 'object' || this.subtask === null) {
throw new Error('Parsed subtask is not an object'); 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.$nextTick(() => {
this.$refs['description']?.setHTML( this.$refs['description']?.setHTML(
this.subtask.description || '', this.subtask.description || '',
@@ -767,7 +798,7 @@ export default {
this.$router.replace({ name: this.$route.name }); this.$router.replace({ name: this.$route.name });
}, },
async validateAllFields(concept = false) { async validate(concept = false) {
if (concept === false) { if (concept === false) {
const employeesValid = await this._validateEmployees(); const employeesValid = await this._validateEmployees();
const timeblocksValid = await this._validateTimeblocks(); const timeblocksValid = await this._validateTimeblocks();
@@ -786,7 +817,7 @@ export default {
async save(concept = false) { async save(concept = false) {
this.wasFormValidationTriggered = true; this.wasFormValidationTriggered = true;
if (!(await this.validateAllFields(concept))) return; if (!(await this.validate(concept))) return;
this.subtask.description = this.$refs['description'].getHTML(); this.subtask.description = this.$refs['description'].getHTML();
this._mergeDeadlineTimeDate(); this._mergeDeadlineTimeDate();
@@ -806,12 +837,15 @@ export default {
}); });
// Convert timeblocks from internal format to BE format for update // 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 const timeblocks = this.timeblocks
.filter( .filter(
(timeblock) => (timeblock) =>
timeblock.date && timeblock.date &&
timeblock.starts_at_time &&
timeblock.ends_at_time &&
timeblock.employee_id === this.subtask.employee_id, timeblock.employee_id === this.subtask.employee_id,
) // Only send timeblocks for this employee ) // Only send complete timeblocks for this employee
.map((timeblock) => { .map((timeblock) => {
const dateStr = moment(timeblock.date).format('YYYY-MM-DD'); const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
return { return {
@@ -839,6 +873,7 @@ export default {
payload, payload,
); );
localStorage.removeItem('subtaskData'); localStorage.removeItem('subtaskData');
localStorage.removeItem('subtaskTimeblocks');
this.$store.commit('tm/update_subtask', subtask); this.$store.commit('tm/update_subtask', subtask);
this.$events.emit('on-subtask-edited', this.subtask); this.$events.emit('on-subtask-edited', this.subtask);
@@ -849,11 +884,15 @@ export default {
); );
// Assign timeblocks to each employee based on employee_id // Assign timeblocks to each employee based on employee_id
// Filter out empty/incomplete timeblocks - only send fully filled timeblocks
validEmployees.forEach((employee) => { validEmployees.forEach((employee) => {
employee.timeblocks = this.timeblocks employee.timeblocks = this.timeblocks
.filter( .filter(
(timeblock) => (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) => { .map((timeblock) => {
return { return {
@@ -881,6 +920,7 @@ export default {
subtaskCopy, subtaskCopy,
); );
localStorage.removeItem('subtaskData'); localStorage.removeItem('subtaskData');
localStorage.removeItem('subtaskTimeblocks');
this.subtask.id = subtasks[0].id; this.subtask.id = subtasks[0].id;
subtasks.forEach((subtask) => { subtasks.forEach((subtask) => {
@@ -941,51 +981,47 @@ export default {
async _validateEmployees() { async _validateEmployees() {
if (this.subtask.id) { if (this.subtask.id) {
// For existing subtasks, at least one employee is required // For existing subtasks, at least one employee is required
// Component-level validation will handle the conditional validation
const hasEmployee = !!( const hasEmployee = !!(
this.subtask.employee_id && this.subtask.duration this.subtask.employee_id && this.subtask.duration
); );
if (!hasEmployee) { return hasEmployee;
this.$toast.error('Je potrebné vybrať zamestnanca a trvanie');
return false;
}
} else { } else {
// For new subtasks, require at least one complete employee (id + duration) let valid = true;
const validEmployees = this.subtask.employees.filter(
(employee) => employee.id && employee.duration,
);
if (validEmployees.length === 0) { // Validate ALL employee component refs (this will show errors on inputs)
this.$toast.error( for (let i = 0; i < this.employeeRefs.length; i++) {
'Je potrebné pridať aspoň jedného zamestnanca s trvaním', const employeeRef = this.employeeRefs[i];
);
return false;
}
// Validate all employee component refs (includes employee select and timeblocks validation) // Validate employees that have any data (partial or complete)
for (const employeeRef of this.employeeRefs) {
if (employeeRef && typeof employeeRef.validate === 'function') { if (employeeRef && typeof employeeRef.validate === 'function') {
const isValid = await employeeRef.validate(); if (!(await employeeRef.validate())) {
if (!isValid) { valid = false;
return false;
} }
} }
} }
}
return true; return valid;
}
}, },
async _validateTimeblocks() { 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; let valid = true;
for (const timeblock of this.timeblocks) { for (const timeblock of this.timeblocks) {
if ( const hasStart = !!timeblock.starts_at_time;
!timeblock.date || const hasEnd = !!timeblock.ends_at_time;
!timeblock.starts_at_time || const hasAnyTime = hasStart || hasEnd;
!timeblock.ends_at_time
) { // 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; valid = false;
break; break;
} }
@@ -1002,7 +1038,6 @@ export default {
); );
if (end.isSameOrBefore(start)) { if (end.isSameOrBefore(start)) {
this.$toast.error('Koniec času musí byť po začiatku');
valid = false; valid = false;
break; break;
} }