mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 22:39:34 +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();
|
||||
|
||||
Reference in new issue
Block a user