mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 06:20:21 +00:00
wip(task-timeline): stage changes
This commit is contained in:
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>
|
||||||
+132
-74
@@ -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][];
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user