wip(task-timeline): stage changes

This commit is contained in:
Matej Glasnák committed 2025-12-08 00:50:51 +01:00
1 parent b1771ead05
commit 69ea8be701
7 files changed
+539 -526

No files matched your search

@@ -1,238 +0,0 @@
<template>
<div class="employees-container">
<!-- Edit Mode: Single Employee -->
<template v-if="isEditMode">
<div class="employee-row">
<div
class="employee-wrapper"
:class="{ 'hide-arrow': modelValue.employee_id }"
>
<svg
v-if="modelValue.employee_id"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
class="dp__icon dp__clear_icon dp__input_icons"
data-test="clear-icon"
@click.prevent="handleRemoveEmployee()"
>
<path
d="M23.057 7.057l-16 16c-0.52 0.52-0.52 1.365 0 1.885s1.365 0.52 1.885 0l16-16c0.52-0.52 0.52-1.365 0-1.885s-1.365-0.52-1.885 0z"
></path>
<path
d="M7.057 8.943l16 16c0.52 0.52 1.365 0.52 1.885 0s0.52-1.365 0-1.885l-16-16c-0.52-0.52-1.365-0.52-1.885 0s-0.52 1.365 0 1.885z"
></path>
</svg>
<v-autocomplete
:model-value="modelValue.employee_id"
:custom-filter="customFilter"
:match-any="true"
label="Priradený"
:items="enrichedEmployees"
item-title="_fullName"
item-value="id"
variant="solo"
class="modal-input"
:class="{ error: validationErrors?.[0]?.employee }"
@update:model-value="updateEmployee"
/>
</div>
<v-text-field
:model-value="modelValue.duration"
class="modal-input"
variant="solo"
type="number"
label="Časový odhad"
:class="{ error: validationErrors?.[0]?.duration }"
append-inner-icon="mdi-clock-outline"
@update:model-value="updateDuration"
/>
</div>
</template>
<!-- Create Mode: Multiple Employees -->
<template v-else>
<div
v-for="(employee, index) in modelValue.employees"
:key="`employee-${index}`"
class="employee-row"
>
<div class="employee-wrapper" :class="{ 'hide-arrow': employee.id }">
<svg
v-if="employee.id"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
class="dp__icon dp__clear_icon dp__input_icons"
data-test="clear-icon"
@click.prevent="removeEmployee(index)"
>
<path
d="M23.057 7.057l-16 16c-0.52 0.52-0.52 1.365 0 1.885s1.365 0.52 1.885 0l16-16c0.52-0.52 0.52-1.365 0-1.885s-1.365-0.52-1.885 0z"
></path>
<path
d="M7.057 8.943l16 16c0.52 0.52 1.365 0.52 1.885 0s0.52-1.365 0-1.885l-16-16c-0.52-0.52-1.365-0.52-1.885 0s-0.52 1.365 0 1.885z"
></path>
</svg>
<v-autocomplete
:model-value="employee.id"
:custom-filter="customFilter"
:match-any="true"
label="Priradený"
:items="enrichedEmployees"
item-title="_fullName"
item-value="id"
variant="solo"
class="modal-input"
:class="{ error: validationErrors?.[index]?.employee }"
@update:model-value="(val) => updateEmployeeInArray(index, val)"
/>
</div>
<v-text-field
:model-value="employee.duration"
class="modal-input"
variant="solo"
type="number"
label="Časový odhad"
:class="{ error: validationErrors?.[index]?.duration }"
append-inner-icon="mdi-clock-outline"
@update:model-value="(val) => updateDurationInArray(index, val)"
/>
</div>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ATaskEmployees',
props: {
modelValue: {
type: Object,
required: true,
},
isEditMode: {
type: Boolean,
default: false,
},
validationErrors: {
type: Array,
default: () => [],
},
customFilter: {
type: Function,
default: null,
},
},
emits: ['update:modelValue', 'removeEmployee', 'clearValidation'],
computed: {
...mapGetters('tm', ['enrichedEmployees']),
},
methods: {
updateEmployee(value) {
this.$emit('update:modelValue', {
...this.modelValue,
employee_id: value,
});
this.$emit('clearValidation');
},
updateDuration(value) {
this.$emit('update:modelValue', {
...this.modelValue,
duration: value,
});
this.$emit('clearValidation');
},
updateEmployeeInArray(index, value) {
const employees = [...this.modelValue.employees];
employees[index] = { ...employees[index], id: value };
this.$emit('update:modelValue', {
...this.modelValue,
employees,
});
this.$emit('clearValidation', index);
},
updateDurationInArray(index, value) {
const employees = [...this.modelValue.employees];
employees[index] = { ...employees[index], duration: value };
this.$emit('update:modelValue', {
...this.modelValue,
employees,
});
this.$emit('clearValidation', index);
},
removeEmployee(index) {
const employees = [...this.modelValue.employees];
const employeeId = employees[index]?.id;
if (employeeId) {
this.$emit('removeEmployee', employeeId);
}
employees.splice(index, 1);
this.$emit('update:modelValue', {
...this.modelValue,
employees,
});
},
handleRemoveEmployee() {
if (this.modelValue.employee_id) {
this.$emit('removeEmployee', this.modelValue.employee_id);
}
},
},
};
</script>
<style lang="sass" scoped>
.employees-container
width: 100%
margin-bottom: 20px
.employee-row
display: flex
gap: 15px
margin-bottom: 15px
align-items: flex-start
@media (max-width: 768px)
flex-direction: column
gap: 0
.employee-wrapper
position: relative
flex: 1
svg
position: absolute
right: 4px
top: 28px
z-index: 5
opacity: .5
cursor: pointer
&.hide-arrow ::v-deep
.mdi-menu-down,
.mdi-menu-up
display: none
.modal-input
margin-bottom: 0
&:last-child
flex: 0 0 auto
min-width: 200px
@media (max-width: 768px)
min-width: 100%
</style>
@@ -1,135 +0,0 @@
<template>
<div class="timeblock w-100">
<div v-if="deletable && editable" class="x-button">
<button class="x-button" @click="$emit('removeTimeblock')">x</button>
</div>
<div class="datepicker-wrapper">
<Z-datepicker
:model-value="timeblock.date"
label="Dátum"
format="dd.MM.yyyy"
locale="sk"
select-text="Vybrať"
cancel-text="Zrušiť"
placeholder="Dátum"
:auto-apply="true"
:enable-time-picker="false"
class="mb-5 modal-input datepicker-input"
@update:model-value="updateDate"
/>
</div>
<div class="splitted-time-inputs">
<v-text-field
:value="timeblock.starts_at_time"
:class="{ error: v$?.timeblock.starts_at_time.$errors.length }"
type="time"
variant="solo"
bg-color="white"
label="Začiatok"
@input="updateStartTime"
/>
<v-text-field
:value="timeblock.ends_at_time"
:class="{ error: v$?.timeblock.ends_at_time.$errors.length }"
type="time"
variant="solo"
bg-color="white"
label="Koniec"
@input="updateEndTime"
/>
</div>
</div>
</template>
<script>
import useVuelidate from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
export default {
components: {
ZDatepicker,
},
props: {
timeblock: {
type: Object,
required: true,
},
deletable: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
index: {
type: Number,
},
},
data() {
return {
v$: useVuelidate(),
};
},
validations() {
return {
timeblock: {
starts_at_time: { required: requiredIf(this.timeblock.date) },
ends_at_time: { required: requiredIf(this.timeblock.date) },
},
};
},
methods: {
updateDate(date) {
const updated = { ...this.timeblock, date };
this.$emit('updateTimeblock', updated);
},
updateStartTime(event) {
const updated = { ...this.timeblock, starts_at_time: event.target.value };
this.$emit('updateTimeblock', updated);
},
updateEndTime(event) {
const updated = { ...this.timeblock, ends_at_time: event.target.value };
this.$emit('updateTimeblock', updated);
},
},
};
</script>
<style lang="sass" scoped>
.timeblock
position: relative
border-top: 1px solid #e0e0e0
padding: 20px 0 5px 0
.splitted-time-inputs
display: flex !important
flex-direction: row !important
gap: 15px
margin-bottom: -11px
.x-button
display: flex
justify-content: flex-end
align-items: center
position: relative
bottom: 5px
button
margin-right: 5px
.error input
border: 1px solid red !important
.datepicker-wrapper
width: 100%
</style>
@@ -0,0 +1,137 @@
<template>
<div class="employee-select">
<v-autocomplete
:model-value="modelValue.id"
:custom-filter="customFilter"
:match-any="true"
label="Priradený"
:items="enrichedEmployees"
item-title="_fullName"
item-value="id"
variant="solo"
class="employee-select__select-field"
:class="{ error: v$.$errors.length && v$.id.$errors.length }"
@update:model-value="handleUpdateEmployee"
/>
<v-text-field
:model-value="modelValue.duration"
class="employee-select__duration-field"
variant="solo"
type="number"
label="Časový odhad"
:class="{ error: v$.$errors.length && v$.duration.$errors.length }"
append-inner-icon="mdi-clock-outline"
@update:model-value="handleUpdateDuration"
/>
<button
v-if="modelValue.id"
class="employee-select__delete-button"
@click="handleClearEmployee"
>
<v-icon icon="mdi-close" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import type { EmployeeData, CustomFilterFunction } from './types';
interface Props {
modelValue: EmployeeData;
customFilter?: CustomFilterFunction;
}
const props = withDefaults(defineProps<Props>(), {
customFilter: undefined,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: EmployeeData): void;
(e: 'clearEmployee'): void;
}>();
const store = useStore();
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
// Vuelidate rules
const rules = {
id: { required },
duration: { required },
};
const v$ = useVuelidate(rules, props.modelValue);
const handleUpdateEmployee = (value: number) => {
emit('update:modelValue', {
...props.modelValue,
id: value,
});
v$.value.$reset();
};
const handleUpdateDuration = (value: string) => {
emit('update:modelValue', {
...props.modelValue,
duration: parseFloat(value) || null,
});
v$.value.$reset();
};
const handleClearEmployee = () => {
emit('clearEmployee');
};
// Expose validate method
const validate = async () => {
const isValid = await v$.value.$validate();
return isValid;
};
defineExpose({
validate,
});
</script>
<style lang="sass" scoped>
.employee-select
display: flex
justify-content: space-between
align-items: start
gap: 10px
&__select-field
width: 100%
&.error
::v-deep .v-field
border-bottom: 2px solid #ff5252 !important
&__duration-field
width: 230px
&__delete-button
background: #ff5252
color: white
border: none
border-radius: 4px
width: 56px
height: 56px
font-size: 16px
cursor: pointer
display: flex
align-items: center
justify-content: center
transition: all 0.2s
line-height: 1
flex-shrink: 0
&:hover
background: #ff1744
</style>
@@ -1,17 +1,16 @@
<template> <template>
<div class="timeblock-container"> <div class="timeblock">
<!-- Date and Time Controls --> <!-- Date and Time Controls -->
<div class="timeblock-header"> <div class="timeblock__header">
<div class="date-navigation"> <div class="timeblock__date">
<button class="nav-btn" @click="previousDay"> <button class="timeblock__date-button" @click="previousDay">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /> <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg> </svg>
</button> </button>
<Z-datepicker <Z-datepicker
:model-value="timeblock.date" :model-value="modelValue.date"
label="Dátum"
format="dd.MM.yyyy" format="dd.MM.yyyy"
locale="sk" locale="sk"
select-text="Vybrať" select-text="Vybrať"
@@ -20,22 +19,25 @@
:auto-apply="true" :auto-apply="true"
:enable-time-picker="false" :enable-time-picker="false"
:min-date="minDate" :min-date="minDate"
class="date-picker-input" class="timeblock__date-picker"
@update:model-value="updateDate" @update:model-value="updateDate"
/> />
<button class="nav-btn" @click="nextDay"> <button class="timeblock__date-button" @click="nextDay">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" /> <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg> </svg>
</button> </button>
</div> </div>
<div class="time-controls"> <div class="timeblock__controls">
<v-text-field <v-text-field
:value="timeblock.starts_at_time" :value="modelValue.starts_at_time"
:class="{ :class="{
'error-input': collision.startCollides || !isTimeRangeValid, 'error-input':
collision.startCollides ||
!isTimeRangeValid ||
(v$.$errors.length && v$.starts_at_time.$errors.length),
}" }"
type="time" type="time"
variant="solo" variant="solo"
@@ -46,8 +48,13 @@
@input="updateStartTime" @input="updateStartTime"
/> />
<v-text-field <v-text-field
:value="timeblock.ends_at_time" :value="modelValue.ends_at_time"
:class="{ 'error-input': collision.endCollides || !isTimeRangeValid }" :class="{
'error-input':
collision.endCollides ||
!isTimeRangeValid ||
(v$.$errors.length && v$.ends_at_time.$errors.length),
}"
type="time" type="time"
variant="solo" variant="solo"
bg-color="white" bg-color="white"
@@ -73,11 +80,11 @@
</div> </div>
<!-- Timeline Visualization --> <!-- Timeline Visualization -->
<div v-if="timeblock.date && employee" class="timeline-wrapper"> <div v-if="modelValue.date && employee" class="timeline">
<div v-if="isLoading" class="timeline-loading"> <div v-if="isLoading" class="timeline-loading">
<span>Načítavam...</span> <span>Načítavam...</span>
</div> </div>
<div v-else class="timeline-container" ref="timelineContainer"> <div v-else class="timeline__content" ref="timelineContainer">
<div class="timeline-scroll" ref="timelineScroll"> <div class="timeline-scroll" ref="timelineScroll">
<div class="timeline-hours"> <div class="timeline-hours">
<div <div
@@ -166,7 +173,7 @@
</div> </div>
<!-- Time Statistics --> <!-- Time Statistics -->
<div class="time-stats"> <!-- <div class="time-stats">
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Naplánované:</span> <span class="stat-label">Naplánované:</span>
<span class="stat-value">{{ scheduledTime }}h</span> <span class="stat-value">{{ scheduledTime }}h</span>
@@ -175,37 +182,24 @@
<span class="stat-label">Voľné:</span> <span class="stat-label">Voľné:</span>
<span class="stat-value">{{ freeTime }}h</span> <span class="stat-value">{{ freeTime }}h</span>
</div> </div>
</div> </div> -->
</div> </div>
</div> </div>
</template> </template>
<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 { required, requiredIf } 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 ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue'; import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
import moment from 'moment'; import moment from 'moment';
import type { TimeblockData, EmployeeFullData } from './types';
interface Timeblock {
id?: number | null;
date: Date | string | null;
starts_at_time: string | null;
ends_at_time: string | null;
employee_id?: number | null;
}
interface Employee {
id: number;
name: string;
surname: string;
work_start: number;
work_end: number;
}
interface Props { interface Props {
timeblock: Timeblock; modelValue: TimeblockData;
employee: Employee | null; employee: EmployeeFullData | null;
deletable?: boolean; deletable?: boolean;
index?: number; index?: number;
} }
@@ -216,7 +210,7 @@ const props = withDefaults(defineProps<Props>(), {
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'updateTimeblock', value: Timeblock): void; (e: 'update:modelValue', value: TimeblockData): void;
(e: 'removeTimeblock'): void; (e: 'removeTimeblock'): void;
}>(); }>();
@@ -259,10 +253,23 @@ const hours = computed(() => {
return result; return result;
}); });
// Vuelidate rules
const rules = {
date: {},
starts_at_time: {
required: requiredIf(() => !!props.modelValue.date),
},
ends_at_time: {
required: requiredIf(() => !!props.modelValue.date),
},
};
const v$ = useVuelidate(rules, props.modelValue);
// Reactive refs for composable // Reactive refs for composable
const employeeRef = computed(() => props.employee); const employeeRef = computed(() => props.employee);
const dateRef = computed(() => props.timeblock.date); const dateRef = computed(() => props.modelValue.date);
const timeblockRef = computed(() => props.timeblock); const timeblockRef = computed(() => props.modelValue);
// Use timeline composable // Use timeline composable
const { const {
@@ -278,9 +285,9 @@ const {
// Force refetch when date changes // Force refetch when date changes
watch( watch(
() => props.timeblock.date, () => props.modelValue.date,
() => { () => {
if (props.timeblock.date && props.employee?.id) { if (props.modelValue.date && props.employee?.id) {
refetch(); refetch();
} }
}, },
@@ -293,13 +300,13 @@ const scheduledBlocks = computed(() => {
// Get current timeblock range // Get current timeblock range
const currentTimeblockRange = computed(() => { const currentTimeblockRange = computed(() => {
const { start, end } = timeblockToDateRange(props.timeblock); const { start, end } = timeblockToDateRange(props.modelValue);
return { start, end }; return { start, end };
}); });
// Validate that end time is after start time // Validate that end time is after start time
const isTimeRangeValid = computed(() => { const isTimeRangeValid = computed(() => {
if (!props.timeblock.starts_at_time || !props.timeblock.ends_at_time) { if (!props.modelValue.starts_at_time || !props.modelValue.ends_at_time) {
return true; // Don't show error if fields are empty return true; // Don't show error if fields are empty
} }
const { start, end } = currentTimeblockRange.value; const { start, end } = currentTimeblockRange.value;
@@ -342,7 +349,7 @@ const centerTimeline = () => {
// Watch for date changes to re-center // Watch for date changes to re-center
watch( watch(
() => props.timeblock.date, () => props.modelValue.date,
() => { () => {
nextTick(() => { nextTick(() => {
centerTimeline(); centerTimeline();
@@ -352,9 +359,9 @@ watch(
// Date navigation // Date navigation
const previousDay = () => { const previousDay = () => {
if (!props.timeblock.date) return; if (!props.modelValue.date) return;
const currentDate = moment(props.timeblock.date); const currentDate = moment(props.modelValue.date);
const newDate = currentDate.subtract(1, 'day').toDate(); const newDate = currentDate.subtract(1, 'day').toDate();
// Don't allow going to past dates // Don't allow going to past dates
@@ -364,9 +371,9 @@ const previousDay = () => {
}; };
const nextDay = () => { const nextDay = () => {
if (!props.timeblock.date) return; if (!props.modelValue.date) return;
const currentDate = moment(props.timeblock.date); const currentDate = moment(props.modelValue.date);
const newDate = currentDate.add(1, 'day').toDate(); const newDate = currentDate.add(1, 'day').toDate();
updateDate(newDate); updateDate(newDate);
@@ -374,49 +381,71 @@ const nextDay = () => {
// Update handlers // Update handlers
const updateDate = (date: Date | null) => { const updateDate = (date: Date | null) => {
emit('updateTimeblock', { ...props.timeblock, date }); emit('update:modelValue', { ...props.modelValue, date });
v$.value.$reset();
}; };
const updateStartTime = (event: Event) => { const updateStartTime = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
emit('updateTimeblock', { ...props.timeblock, starts_at_time: target.value }); emit('update:modelValue', {
...props.modelValue,
starts_at_time: target.value,
});
v$.value.$reset();
}; };
const updateEndTime = (event: Event) => { const updateEndTime = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
emit('updateTimeblock', { ...props.timeblock, ends_at_time: target.value }); emit('update:modelValue', {
...props.modelValue,
ends_at_time: target.value,
});
v$.value.$reset();
}; };
// Expose validate method
const validate = async () => {
const isValid = await v$.value.$validate();
return isValid && isTimeRangeValid.value;
};
defineExpose({
validate,
});
</script> </script>
<style lang="sass" scoped> <style lang="sass" scoped>
.timeblock-container .timeblock
width: 100% width: 100%
padding: 20px 0 border: 1px solid #e5e5e5
border-top: 1px solid #e0e0e0 border-radius: 8px
margin-bottom: 8px
.timeblock-header &__header
display: flex display: flex
align-items: center align-items: center
gap: 15px gap: 15px
margin-bottom: 20px
flex-wrap: wrap flex-wrap: wrap
border-bottom: 1px solid #e5e5e5
padding: 8px 16px
@media (max-width: 768px) @media (max-width: 768px)
flex-direction: column flex-direction: column
align-items: stretch align-items: stretch
.date-navigation &__date
display: flex display: flex
align-items: center align-items: center
gap: 10px
flex: 1 flex: 1
max-width: 220px
// gap: 16px
.nav-btn &__date-button
background: #f5f5f5 background: #f5f5f5
border: 1px solid #e0e0e0 border: 1px solid #e0e0e0
border-radius: 4px border-radius: 4px
width: 36px width: 44px
height: 36px height: 44px
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: center
@@ -429,10 +458,36 @@ const updateEndTime = (event: Event) => {
&:active &:active
background: #d0d0d0 background: #d0d0d0
.date-picker-input &:first-child
flex: 1 border-top-right-radius: 0
border-bottom-right-radius: 0
border-right: none
.time-controls &:last-child
border-top-left-radius: 0
border-bottom-left-radius: 0
border-left: none
&__date-picker
flex: 1
border-radius: 0
::v-deep
.dp__input
border-radius: 0
height: 44px
.dp__clear_icon
display: none
&.has-value ::v-deep
.dp__input
padding-top: 13px !important
padding-bottom: 11px !important
.dp__icon
top: 23px !important
&__controls
display: flex display: flex
gap: 10px gap: 10px
flex: 1 flex: 1
@@ -441,7 +496,8 @@ const updateEndTime = (event: Event) => {
width: 100% width: 100%
::v-deep .v-input ::v-deep .v-input
min-width: 120px width: 140px !important
flex-grow: 0
.error-input .error-input
::v-deep input ::v-deep input
@@ -475,19 +531,22 @@ const updateEndTime = (event: Event) => {
&:hover &:hover
background: #ff1744 background: #ff1744
.timeline-wrapper .timeline
margin-bottom: 15px height: 98px
.timeline-loading &__content
text-align: center
padding: 40px
color: #666
.timeline-container
position: relative position: relative
width: 100% width: 100%
margin-bottom: 10px margin-bottom: 10px
&__loading
height: 100%
width: 100%
display: flex
align-items: center
justify-content: center
color: #666
.timeline-scroll .timeline-scroll
overflow-x: auto overflow-x: auto
overflow-y: hidden overflow-y: hidden
@@ -524,7 +583,7 @@ const updateEndTime = (event: Event) => {
.hour-label .hour-label
position: absolute position: absolute
top: 5px top: 7px
left: 5px left: 5px
font-size: 11px font-size: 11px
color: #666 color: #666
@@ -624,4 +683,3 @@ const updateEndTime = (event: Event) => {
font-weight: bold font-weight: bold
color: #7f39fb color: #7f39fb
</style> </style>
@@ -0,0 +1,166 @@
<template>
<div class="employee" :class="{ 'employee--has-background': hasBackground }">
<AEmployeeSelect
ref="employeeSelectRef"
v-model="employeeDataLocal"
:custom-filter="customFilter"
@clear-employee="handleClearEmployee"
/>
<!-- Timeblocks Timeline -->
<div v-if="selectedEmployee" class="employee__timeblocks">
<AEmployeeTimeblock
v-for="(timeblock, index) in timeblocks"
:key="`timeblock-${index}`"
:ref="(el) => setTimeblockRef(el, index)"
:model-value="timeblock"
:employee="selectedEmployee"
:deletable="timeblocks.length > 1"
:index="index"
@update:model-value="(val) => handleUpdateTimeblock(index, val)"
@remove-timeblock="handleRemoveTimeblock(index)"
/>
<!-- Add Timeblock Button -->
<div class="add-timeblock-btn-wrapper">
<button class="add-timeblock-btn" @click="handleAddTimeblock">
Pridať časový blok
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useStore } from 'vuex';
import AEmployeeSelect from './a-employee-select.vue';
import AEmployeeTimeblock from './a-employee-timeblock.vue';
import type {
EmployeeData,
TimeblockData,
CustomFilterFunction,
} from './types';
interface Props {
employeeData: EmployeeData;
timeblocks?: TimeblockData[];
customFilter?: CustomFilterFunction;
hasBackground?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
timeblocks: () => [],
customFilter: undefined,
hasBackground: true,
});
const emit = defineEmits<{
(e: 'update:employeeData', value: EmployeeData): void;
(e: 'update:timeblocks', value: TimeblockData[]): void;
(e: 'clearEmployee'): void;
(e: 'addTimeblock'): void;
}>();
const store = useStore();
// Local models with v-model sync
const employeeDataLocal = computed({
get: () => props.employeeData,
set: (value: EmployeeData) => emit('update:employeeData', value),
});
// Refs for validation
const employeeSelectRef = ref<InstanceType<typeof AEmployeeSelect> | null>(
null,
);
const timeblockRefs = ref<any[]>([]);
const setTimeblockRef = (el: any, index: number) => {
if (el) {
timeblockRefs.value[index] = el;
}
};
// Get the full employee object from store
const selectedEmployee = computed(() => {
if (!props.employeeData.id) return null;
const employees = store.getters['tm/enrichedEmployees'];
return employees.find((emp: any) => emp.id === props.employeeData.id) || null;
});
const handleClearEmployee = () => {
emit('clearEmployee');
};
const handleUpdateTimeblock = (index: number, value: TimeblockData) => {
const updatedTimeblocks = [...props.timeblocks];
updatedTimeblocks[index] = value;
emit('update:timeblocks', updatedTimeblocks);
};
const handleRemoveTimeblock = (index: number) => {
const updatedTimeblocks = [...props.timeblocks];
updatedTimeblocks.splice(index, 1);
emit('update:timeblocks', updatedTimeblocks);
};
const handleAddTimeblock = () => {
emit('addTimeblock');
};
// 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();
isValid = isValid && selectValid;
}
// Validate all timeblocks
for (const timeblockRef of timeblockRefs.value) {
if (timeblockRef) {
const timeblockValid = await timeblockRef.validate();
isValid = isValid && timeblockValid;
}
}
return isValid;
};
defineExpose({
validate,
});
</script>
<style lang="sass" scoped>
.employee
width: 100%
margin-bottom: 16px
&--has-background
border: 1px solid #e5e5e5
padding: 16px
border-radius: 8px
.add-timeblock-btn-wrapper
width: 100%
display: flex
justify-content: flex-end
margin-top: 15px
.add-timeblock-btn
padding: 10px 20px
background-color: #7f39fb
color: white
border: none
border-radius: 4px
cursor: pointer
font-size: 14px
transition: background-color 0.2s
&:hover
background-color: #6825e0
</style>
@@ -0,0 +1,29 @@
// Universal interfaces for employee components
export interface EmployeeData {
id: number | null;
duration: number | null;
}
export interface TimeblockData {
id?: number | null;
date: Date | string | null;
starts_at_time: string | null;
ends_at_time: string | null;
employee_id?: number | null;
}
export interface EmployeeFullData {
id: number;
name: string;
surname: string;
work_start: number;
work_end: number;
_fullName?: string;
}
export type CustomFilterFunction = (
value: string,
query: string,
item?: any,
) => boolean | number | [number, number] | [number, number][];
+75 -79
View File
@@ -88,85 +88,33 @@
/> />
</div> </div>
<!-- Employee Assignment --> <!-- Employee Assignment with Timeblocks -->
<A-task-employees
v-model="subtask"
:is-edit-mode="!!subtask.id"
:validation-errors="employeeValidations"
:custom-filter="customFilter"
@remove-employee="handleRemoveEmployee"
@clear-validation="clearEmployeeValidation"
/>
<!-- Timeblocks per Employee -->
<div class="employees-timeblocks w-100">
<template v-if="subtask.id">
<!-- Edit Mode: Single Employee --> <!-- Edit Mode: Single Employee -->
<div <A-employee
v-if="subtask.employee_id" v-if="subtask.id"
class="employee-timeblocks-section" v-model:employee-data="editModeEmployee"
> :timeblocks="getEmployeeTimeblocks(subtask.employee_id)"
<div class="employee-header"> :custom-filter="customFilter"
<h3>{{ getEmployeeFullName(subtask.employee_id) }}</h3> @update:timeblocks="
</div> updateEmployeeTimeblocks($event, subtask.employee_id)
<A-task-employee-timeline
v-for="(timeblock, index) in getEmployeeTimeblocks(
subtask.employee_id,
)"
:key="`timeblock-${index}`"
:timeblock="timeblock"
:employee="getEmployeeById(subtask.employee_id)"
:deletable="
getEmployeeTimeblocks(subtask.employee_id).length > 1
"
:index="index"
@update-timeblock="
(val) => updateTimeblock(val, index, subtask.employee_id)
"
@remove-timeblock="
removeTimeblock(index, subtask.employee_id)
" "
@clear-employee="handleRemoveEmployee"
@add-timeblock="addTimeblock(subtask.employee_id)"
/> />
<div class="end-button w-100">
<button @click="addTimeblock(subtask.employee_id)">
Pridať časový blok
</button>
</div>
</div>
</template>
<template v-else>
<!-- Create Mode: Multiple Employees --> <!-- Create Mode: Multiple Employees -->
<div <template v-else>
v-for="employee in getValidEmployees()" <A-employee
:key="`employee-${employee.id}`" v-for="(employee, index) in getValidEmployees()"
class="employee-timeblocks-section" :key="`employee-${employee.id || index}`"
> v-model:employee-data="subtask.employees[index]"
<div class="employee-header"> :timeblocks="getEmployeeTimeblocks(employee.id)"
<h3>{{ getEmployeeFullName(employee.id) }}</h3> :custom-filter="customFilter"
</div> @update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
<A-task-employee-timeline @clear-employee="() => removeEmployeeAtIndex(index)"
v-for="(timeblock, index) in getEmployeeTimeblocks( @add-timeblock="addTimeblock(employee.id)"
employee.id,
)"
:key="`timeblock-${employee.id}-${index}`"
:timeblock="timeblock"
:employee="getEmployeeById(employee.id)"
:deletable="getEmployeeTimeblocks(employee.id).length > 1"
:index="index"
@update-timeblock="
(val) => updateTimeblock(val, index, employee.id)
"
@remove-timeblock="removeTimeblock(index, employee.id)"
/> />
<div class="end-button w-100">
<button @click="addTimeblock(employee.id)">
Pridať časový blok
</button>
</div>
</div>
</template> </template>
</div>
<div class="w-100 modal-input quill-editor-wrapper"> <div class="w-100 modal-input quill-editor-wrapper">
<QuillEditor <QuillEditor
@@ -317,8 +265,7 @@ import '@vueup/vue-quill/dist/vue-quill.snow.css';
import AutocompleteMixin from '@/plugins/tm@_mixins/autocomplete.mixin'; import AutocompleteMixin from '@/plugins/tm@_mixins/autocomplete.mixin';
import ZTaskReadonlyInfo from './_components/z-task-readonly-info.vue'; import ZTaskReadonlyInfo from './_components/z-task-readonly-info.vue';
import ATaskEmployees from './_components/a-task-employees.vue'; import AEmployee from './_components/employees/a-employee.vue';
import ATaskEmployeeTimeline from './_components/a-task-employee-timeline.vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import modalMixin from '@/plugins/tm@modal/_mixins/modal.mixin'; 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';
@@ -328,8 +275,7 @@ import ACloseButton from '@/plugins/tm@modal/_components/a-close-button.vue';
export default { export default {
components: { components: {
ZTaskReadonlyInfo, ZTaskReadonlyInfo,
ATaskEmployees, AEmployee,
ATaskEmployeeTimeline,
ZFilesUpload, ZFilesUpload,
ZDatepicker, ZDatepicker,
// ATaskTimeblocks, // ATaskTimeblocks,
@@ -367,6 +313,20 @@ export default {
_fullName: `${mastertask.name} [${mastertask.project_code}]`, _fullName: `${mastertask.name} [${mastertask.project_code}]`,
})); }));
}, },
editModeEmployee: {
get() {
return {
id: this.subtask?.employee_id || null,
duration: this.subtask?.duration || null,
};
},
set(value) {
if (this.subtask) {
this.subtask.employee_id = value.id;
this.subtask.duration = value.duration;
}
},
},
}, },
validations() { validations() {
@@ -557,6 +517,19 @@ export default {
return this.timeblocks.filter((tb) => tb.employee_id === employeeId); return this.timeblocks.filter((tb) => tb.employee_id === employeeId);
}, },
updateEmployeeTimeblocks(updatedTimeblocks, employeeId) {
// Remove old timeblocks for this employee
const otherTimeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employeeId,
);
// Add updated timeblocks with employee_id
const timeblocksWithEmployeeId = updatedTimeblocks.map((tb) => ({
...tb,
employee_id: employeeId,
}));
this.timeblocks = [...otherTimeblocks, ...timeblocksWithEmployeeId];
},
// Clear employee validation // Clear employee validation
clearEmployeeValidation(index) { clearEmployeeValidation(index) {
if (index !== undefined && this.employeeValidations[index]) { if (index !== undefined && this.employeeValidations[index]) {
@@ -608,14 +581,37 @@ export default {
}, },
// Handle remove employee - also removes their timeblocks // Handle remove employee - also removes their timeblocks
handleRemoveEmployee(employeeId) { handleRemoveEmployee() {
if (employeeId) { if (this.subtask.employee_id) {
// Remove all timeblocks for this employee // Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter( this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employeeId, (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) {
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,
);
}
// Remove employee from array
this.subtask.employees.splice(index, 1);
if (this.wasFormValidationTriggered) { if (this.wasFormValidationTriggered) {
setTimeout(async () => { setTimeout(async () => {
this.validateAllFields(); this.validateAllFields();