mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 06:20:21 +00:00
feat(task-modal): added timeline to timeblocks in employee modal
This commit is contained in:
1 parent
00c4bf4d65
commit
c55c2ce3e9
7 files changed
+468
-142
No files matched your search
@@ -10,7 +10,8 @@
|
||||
item-value="id"
|
||||
variant="solo"
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -20,7 +21,8 @@
|
||||
variant="solo"
|
||||
type="number"
|
||||
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"
|
||||
@update:model-value="handleUpdateDuration"
|
||||
/>
|
||||
@@ -36,15 +38,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { helpers, required } from '@vuelidate/validators';
|
||||
import type { EmployeeData, CustomFilterFunction } from './types';
|
||||
|
||||
interface Props {
|
||||
modelValue: EmployeeData;
|
||||
customFilter?: CustomFilterFunction;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -60,34 +63,67 @@ const store = useStore();
|
||||
|
||||
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
|
||||
|
||||
// Check if employee has partial data (one field filled but not both)
|
||||
const hasPartialData = computed(() => {
|
||||
const { id, duration } = props.modelValue;
|
||||
return !!(id || duration); // Has data if either field is filled
|
||||
});
|
||||
// Make modelValue reactive for Vuelidate
|
||||
const { modelValue } = toRefs(props);
|
||||
|
||||
// Vuelidate rules - only validate if employee has partial data
|
||||
const rules = {
|
||||
id: { required: requiredIf(hasPartialData) },
|
||||
duration: { required: requiredIf(hasPartialData) },
|
||||
// Custom validator: if either field is filled, both are required
|
||||
const requiredWhenPaired = (fieldName: 'id' | 'duration') => {
|
||||
return (_value: any) => {
|
||||
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) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
id: value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleUpdateDuration = (value: string) => {
|
||||
const parsedValue = parseFloat(value);
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
duration: parseFloat(value) || null,
|
||||
duration: isNaN(parsedValue) ? null : parsedValue,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleClearEmployee = () => {
|
||||
@@ -95,7 +131,19 @@ const handleClearEmployee = () => {
|
||||
};
|
||||
|
||||
// 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();
|
||||
return isValid;
|
||||
};
|
||||
|
||||
@@ -74,8 +74,11 @@
|
||||
@input="$emit('updateEndTime', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isTimeRangeValid" class="validation-error">
|
||||
Koniec musí byť po začiatku
|
||||
<div
|
||||
v-if="!isTimeRangeValid || endTimeErrorMessage"
|
||||
class="validation-error"
|
||||
>
|
||||
{{ endTimeErrorMessage || 'Koniec musí byť po začiatku' }}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -111,9 +114,12 @@ interface Props {
|
||||
hasValidationErrors: boolean;
|
||||
hasStartTimeError: boolean;
|
||||
hasEndTimeError: boolean;
|
||||
endTimeErrorMessage?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
endTimeErrorMessage: '',
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
(e: 'previousDay'): void;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
:is-editable="isEditable"
|
||||
:is-time-range-valid="isTimeRangeValid"
|
||||
:collision="collision"
|
||||
:has-validation-errors="v$.$errors.length > 0"
|
||||
:has-start-time-error="v$.starts_at_time.$errors.length > 0"
|
||||
:has-end-time-error="v$.ends_at_time.$errors.length > 0"
|
||||
:has-validation-errors="v$.$error && v$.$anyDirty"
|
||||
:has-start-time-error="hasStartTimeError"
|
||||
:has-end-time-error="hasEndTimeError"
|
||||
:end-time-error-message="endTimeErrorMessage"
|
||||
@previous-day="previousDay"
|
||||
@next-day="nextDay"
|
||||
@update-date="updateDate"
|
||||
@@ -41,7 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, nextTick } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { helpers } from '@vuelidate/validators';
|
||||
import { useEmployeeTimeline } from '@/plugins/tm@_utils/useEmployeeTimeline';
|
||||
import { timeblockToDateRange } from '@/plugins/tm@_utils/timeblock.utils';
|
||||
import AEmployeeTimeblockHeader from './a-employee-timeblock-header.vue';
|
||||
@@ -111,27 +112,90 @@ const hourWidth = computed(() =>
|
||||
isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH,
|
||||
);
|
||||
|
||||
// Check if timeblock has partial data (any field filled)
|
||||
const hasPartialData = computed(() => {
|
||||
return !!(
|
||||
props.modelValue.date ||
|
||||
props.modelValue.starts_at_time ||
|
||||
props.modelValue.ends_at_time
|
||||
);
|
||||
});
|
||||
// Custom validator for time range
|
||||
const timeRangeValid = (value: any, siblings: any) => {
|
||||
if (!siblings.starts_at_time || !siblings.ends_at_time || !siblings.date) {
|
||||
return true;
|
||||
}
|
||||
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 = {
|
||||
date: {},
|
||||
date: {
|
||||
requiredWhenHasTime: helpers.withMessage(
|
||||
'Vyberte dátum',
|
||||
requiredWhenHasTime,
|
||||
),
|
||||
},
|
||||
starts_at_time: {
|
||||
required: requiredIf(hasPartialData),
|
||||
requiredWhenHasTime: helpers.withMessage(
|
||||
'Zadajte začiatok',
|
||||
requiredWhenHasTime,
|
||||
),
|
||||
},
|
||||
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
|
||||
const employeeRef = computed(() => props.employee);
|
||||
@@ -176,9 +240,13 @@ const unsavedTimeblocksForSameDate = computed(() => {
|
||||
// Exclude the current timeblock
|
||||
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;
|
||||
|
||||
// Must be the same date
|
||||
const tbDate = moment(tb.date).format('YYYY-MM-DD');
|
||||
return tbDate === currentDate;
|
||||
})
|
||||
@@ -231,16 +299,13 @@ watch(
|
||||
);
|
||||
|
||||
// Watch for timeline ref to become available and center it
|
||||
watch(
|
||||
timelineRef,
|
||||
(newRef) => {
|
||||
if (newRef?.timelineScroll) {
|
||||
nextTick(() => {
|
||||
centerTimeline();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(timelineRef, (newRef) => {
|
||||
if (newRef?.timelineScroll) {
|
||||
nextTick(() => {
|
||||
centerTimeline();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Date navigation
|
||||
const previousDay = () => {
|
||||
@@ -267,7 +332,6 @@ const nextDay = () => {
|
||||
// Update handlers
|
||||
const updateDate = (date: Date | null) => {
|
||||
emit('update:modelValue', { ...props.modelValue, date });
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const updateStartTime = (event: Event) => {
|
||||
@@ -276,7 +340,6 @@ const updateStartTime = (event: Event) => {
|
||||
...props.modelValue,
|
||||
starts_at_time: target.value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const updateEndTime = (event: Event) => {
|
||||
@@ -285,11 +348,11 @@ const updateEndTime = (event: Event) => {
|
||||
...props.modelValue,
|
||||
ends_at_time: target.value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
// Expose validate method
|
||||
const validate = async () => {
|
||||
v$.value.$touch();
|
||||
const isValid = await v$.value.$validate();
|
||||
return isValid && isTimeRangeValid.value;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
ref="employeeSelectRef"
|
||||
v-model="employeeDataLocal"
|
||||
:custom-filter="customFilter"
|
||||
:required="required"
|
||||
@clear-employee="handleClearEmployee"
|
||||
/>
|
||||
|
||||
@@ -28,7 +29,14 @@
|
||||
</template>
|
||||
|
||||
<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 AEmployeeSelect from './a-employee-select.vue';
|
||||
import AEmployeeTimeblock from './a-employee-timeblock.vue';
|
||||
@@ -43,12 +51,14 @@ interface Props {
|
||||
timeblocks?: TimeblockData[];
|
||||
customFilter?: CustomFilterFunction;
|
||||
hasBackground?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
timeblocks: () => [],
|
||||
customFilter: undefined,
|
||||
hasBackground: true,
|
||||
required: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -87,17 +97,28 @@ const selectedEmployee = computed(() => {
|
||||
|
||||
// Watch for when employee is selected and ensure at least one timeblock exists
|
||||
watch(
|
||||
() => [props.employeeData.id, props.employeeData.duration],
|
||||
([newId, newDuration]) => {
|
||||
// If employee has both id and duration and there are no timeblocks, request one to be added
|
||||
if (newId && newDuration && props.timeblocks.length === 0) {
|
||||
() => props.employeeData.id,
|
||||
(newId, oldId) => {
|
||||
// If employee just got selected (id changed from null/undefined to a value)
|
||||
// and there are no timeblocks for this employee, request one to be added
|
||||
if (newId && !oldId && props.timeblocks.length === 0) {
|
||||
nextTick(() => {
|
||||
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 = () => {
|
||||
emit('clearEmployee');
|
||||
};
|
||||
@@ -121,7 +142,6 @@ const handleAddTimeblock = (afterIndex: number) => {
|
||||
// Expose validate method that validates all child components
|
||||
const validate = async () => {
|
||||
let isValid = true;
|
||||
|
||||
// Validate employee select
|
||||
if (employeeSelectRef.value) {
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user