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"
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();