mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 14:29:34 +00:00
wip(task-timeline): stage changes
This commit is contained in:
1 parent
69ea8be701
commit
00c4bf4d65
7 files changed
+873
-688
No files matched your search
@@ -26,8 +26,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="modelValue.id"
|
|
||||||
class="employee-select__delete-button"
|
class="employee-select__delete-button"
|
||||||
|
:disabled="!modelValue.id"
|
||||||
@click="handleClearEmployee"
|
@click="handleClearEmployee"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-close" />
|
<v-icon icon="mdi-close" />
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { requiredIf } from '@vuelidate/validators';
|
||||||
import type { EmployeeData, CustomFilterFunction } from './types';
|
import type { EmployeeData, CustomFilterFunction } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -60,10 +60,16 @@ const store = useStore();
|
|||||||
|
|
||||||
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
|
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
|
||||||
|
|
||||||
// Vuelidate rules
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vuelidate rules - only validate if employee has partial data
|
||||||
const rules = {
|
const rules = {
|
||||||
id: { required },
|
id: { required: requiredIf(hasPartialData) },
|
||||||
duration: { required },
|
duration: { required: requiredIf(hasPartialData) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, props.modelValue);
|
const v$ = useVuelidate(rules, props.modelValue);
|
||||||
@@ -109,10 +115,6 @@ defineExpose({
|
|||||||
&__select-field
|
&__select-field
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
&.error
|
|
||||||
::v-deep .v-field
|
|
||||||
border-bottom: 2px solid #ff5252 !important
|
|
||||||
|
|
||||||
&__duration-field
|
&__duration-field
|
||||||
width: 230px
|
width: 230px
|
||||||
|
|
||||||
@@ -132,6 +134,10 @@ defineExpose({
|
|||||||
line-height: 1
|
line-height: 1
|
||||||
flex-shrink: 0
|
flex-shrink: 0
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
opacity: 0.5
|
||||||
|
cursor: not-allowed
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
background: #ff1744
|
background: #ff1744
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeblock__header">
|
||||||
|
<div class="timeblock__date">
|
||||||
|
<button
|
||||||
|
class="timeblock__date-button"
|
||||||
|
:disabled="isCurrentDay || !isEditable"
|
||||||
|
@click="$emit('previousDay')"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Z-datepicker
|
||||||
|
:model-value="modelValue.date"
|
||||||
|
format="dd.MM.yyyy"
|
||||||
|
locale="sk"
|
||||||
|
select-text="Vybrať"
|
||||||
|
cancel-text="Zrušiť"
|
||||||
|
placeholder="Dátum"
|
||||||
|
:auto-apply="true"
|
||||||
|
:enable-time-picker="false"
|
||||||
|
:min-date="minDate"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
class="timeblock__date-picker"
|
||||||
|
:class="{ 'timeblock__date-picker--disabled': !isEditable }"
|
||||||
|
@update:model-value="$emit('updateDate', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="timeblock__date-button"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
@click="$emit('nextDay')"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeblock__controls">
|
||||||
|
<v-text-field
|
||||||
|
:value="modelValue.starts_at_time"
|
||||||
|
:class="{
|
||||||
|
'error-input':
|
||||||
|
collision.startCollides ||
|
||||||
|
!isTimeRangeValid ||
|
||||||
|
(hasValidationErrors && hasStartTimeError),
|
||||||
|
}"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
type="time"
|
||||||
|
variant="solo"
|
||||||
|
bg-color="white"
|
||||||
|
label="Začiatok"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@input="$emit('updateStartTime', $event)"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
:value="modelValue.ends_at_time"
|
||||||
|
:class="{
|
||||||
|
'error-input':
|
||||||
|
collision.endCollides ||
|
||||||
|
!isTimeRangeValid ||
|
||||||
|
(hasValidationErrors && hasEndTimeError),
|
||||||
|
}"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
type="time"
|
||||||
|
variant="solo"
|
||||||
|
bg-color="white"
|
||||||
|
label="Koniec"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@input="$emit('updateEndTime', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isTimeRangeValid" class="validation-error">
|
||||||
|
Koniec musí byť po začiatku
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="add-btn" @click="$emit('addTimeblock')">+</button>
|
||||||
|
<button
|
||||||
|
:disabled="!deletable"
|
||||||
|
class="delete-btn"
|
||||||
|
@click="$emit('removeTimeblock')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import moment from 'moment';
|
||||||
|
import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
|
||||||
|
import type { TimeblockData } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: TimeblockData;
|
||||||
|
minDate: Date;
|
||||||
|
deletable: boolean;
|
||||||
|
isEditable: boolean;
|
||||||
|
isTimeRangeValid: boolean;
|
||||||
|
collision: {
|
||||||
|
hasCollision: boolean;
|
||||||
|
startCollides: boolean;
|
||||||
|
endCollides: boolean;
|
||||||
|
};
|
||||||
|
hasValidationErrors: boolean;
|
||||||
|
hasStartTimeError: boolean;
|
||||||
|
hasEndTimeError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'previousDay'): void;
|
||||||
|
(e: 'nextDay'): void;
|
||||||
|
(e: 'updateDate', value: Date | null): void;
|
||||||
|
(e: 'updateStartTime', event: Event): void;
|
||||||
|
(e: 'updateEndTime', event: Event): void;
|
||||||
|
(e: 'addTimeblock'): void;
|
||||||
|
(e: 'removeTimeblock'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Check if selected date is today (disable previous day button if true)
|
||||||
|
const isCurrentDay = computed(() => {
|
||||||
|
if (!props.modelValue.date) return false;
|
||||||
|
return moment(props.modelValue.date).isSame(moment(), 'day');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.timeblock__header
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 15px
|
||||||
|
flex-wrap: wrap
|
||||||
|
border-bottom: 1px solid #e5e5e5
|
||||||
|
padding: 8px 16px
|
||||||
|
|
||||||
|
@media (max-width: 768px)
|
||||||
|
flex-direction: column
|
||||||
|
align-items: stretch
|
||||||
|
|
||||||
|
.timeblock__date
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
flex: 1
|
||||||
|
max-width: 220px
|
||||||
|
|
||||||
|
.timeblock__date-button
|
||||||
|
background: #f5f5f5
|
||||||
|
border: 1px solid #e0e0e0
|
||||||
|
border-radius: 4px
|
||||||
|
width: 44px
|
||||||
|
height: 44px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.2s
|
||||||
|
|
||||||
|
&:hover:not(:disabled)
|
||||||
|
background: #e0e0e0
|
||||||
|
|
||||||
|
&:active:not(:disabled)
|
||||||
|
background: #d0d0d0
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
opacity: 0.4
|
||||||
|
cursor: not-allowed
|
||||||
|
|
||||||
|
&:first-child
|
||||||
|
border-top-right-radius: 0
|
||||||
|
border-bottom-right-radius: 0
|
||||||
|
border-right: none
|
||||||
|
|
||||||
|
&:last-child
|
||||||
|
border-top-left-radius: 0
|
||||||
|
border-bottom-left-radius: 0
|
||||||
|
border-left: none
|
||||||
|
|
||||||
|
.timeblock__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
|
||||||
|
|
||||||
|
&--disabled
|
||||||
|
opacity: 0.5
|
||||||
|
cursor: not-allowed !important
|
||||||
|
|
||||||
|
::v-deep
|
||||||
|
.dp__input_wrap
|
||||||
|
cursor: not-allowed !important
|
||||||
|
|
||||||
|
.dp__input
|
||||||
|
cursor: not-allowed !important
|
||||||
|
|
||||||
|
.timeblock__controls
|
||||||
|
display: flex
|
||||||
|
gap: 10px
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
@media (max-width: 768px)
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
::v-deep .v-input
|
||||||
|
width: 140px !important
|
||||||
|
flex-grow: 0
|
||||||
|
|
||||||
|
.error-input
|
||||||
|
::v-deep input
|
||||||
|
border-bottom: 2px solid #ff5252 !important
|
||||||
|
|
||||||
|
.validation-error
|
||||||
|
color: #ff5252
|
||||||
|
font-size: 12px
|
||||||
|
margin-top: 5px
|
||||||
|
padding-left: 10px
|
||||||
|
|
||||||
|
.actions
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 8px
|
||||||
|
|
||||||
|
.add-btn
|
||||||
|
background: #7f39fb
|
||||||
|
color: white
|
||||||
|
border: none
|
||||||
|
border-radius: 4px
|
||||||
|
width: 44px
|
||||||
|
height: 44px
|
||||||
|
font-size: 28px
|
||||||
|
cursor: pointer
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
transition: all 0.2s
|
||||||
|
line-height: 1
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: #6825e0
|
||||||
|
|
||||||
|
.delete-btn
|
||||||
|
background: #ff5252
|
||||||
|
color: white
|
||||||
|
border: none
|
||||||
|
border-radius: 4px
|
||||||
|
width: 44px
|
||||||
|
height: 44px
|
||||||
|
font-size: 28px
|
||||||
|
cursor: pointer
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
transition: all 0.2s
|
||||||
|
line-height: 1
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
opacity: 0.5
|
||||||
|
cursor: not-allowed
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: #ff1744
|
||||||
|
</style>
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-hours">
|
||||||
|
<div
|
||||||
|
v-for="hour in hours"
|
||||||
|
:key="hour"
|
||||||
|
class="hour-marker"
|
||||||
|
:style="{ left: `${getHourPosition(hour)}px` }"
|
||||||
|
>
|
||||||
|
<span class="hour-label">{{ hour }}:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
startHour: number;
|
||||||
|
endHour: number;
|
||||||
|
hourWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
// Generate hours array (exclude the last hour label)
|
||||||
|
const hours = computed(() => {
|
||||||
|
const result = [];
|
||||||
|
for (let i = props.startHour; i < props.endHour; i++) {
|
||||||
|
result.push(i);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position calculation
|
||||||
|
const getHourPosition = (hour: number): number => {
|
||||||
|
return (hour - props.startHour) * props.hourWidth;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.timeline-hours
|
||||||
|
position: relative
|
||||||
|
height: 30px
|
||||||
|
width: calc(24 * 60px)
|
||||||
|
|
||||||
|
@media (max-width: 768px)
|
||||||
|
width: calc(24 * 50px)
|
||||||
|
|
||||||
|
.hour-marker
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
height: 30px
|
||||||
|
border-left: 1px solid #e0e0e0
|
||||||
|
|
||||||
|
.hour-label
|
||||||
|
position: absolute
|
||||||
|
top: 7px
|
||||||
|
left: 5px
|
||||||
|
font-size: 11px
|
||||||
|
color: #666
|
||||||
|
white-space: nowrap
|
||||||
|
</style>
|
||||||
|
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-if="isLoading" class="timeline-loading">
|
||||||
|
<span>Načítavam...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="timeline__content" ref="timelineContainer">
|
||||||
|
<div class="timeline-scroll" ref="timelineScroll">
|
||||||
|
<a-employee-timeblock-timeline-header
|
||||||
|
:start-hour="startHour"
|
||||||
|
:end-hour="endHour"
|
||||||
|
:hour-width="hourWidth"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="timeline-bar">
|
||||||
|
<!-- Non-working hours - before work -->
|
||||||
|
<div
|
||||||
|
class="non-working-hours"
|
||||||
|
:style="{
|
||||||
|
left: '0px',
|
||||||
|
width: `${getHourPosition(employee.work_start)}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Non-working hours - after work -->
|
||||||
|
<div
|
||||||
|
class="non-working-hours"
|
||||||
|
:style="{
|
||||||
|
left: `${getHourPosition(employee.work_end)}px`,
|
||||||
|
width: `${getHourWidth(24 - employee.work_end)}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Work hours background (transparent/white) -->
|
||||||
|
<div
|
||||||
|
class="work-hours-bar"
|
||||||
|
:style="{
|
||||||
|
left: `${getHourPosition(employee.work_start)}px`,
|
||||||
|
width: `${getHourWidth(
|
||||||
|
employee.work_end - employee.work_start,
|
||||||
|
)}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Existing scheduled blocks -->
|
||||||
|
<div
|
||||||
|
v-for="(task, index) in scheduledBlocks"
|
||||||
|
:key="`task-${index}`"
|
||||||
|
class="scheduled-block"
|
||||||
|
:style="{
|
||||||
|
left: `${getTimePosition(task._real_start)}px`,
|
||||||
|
width: `${getTimeWidth(task._real_start, task._real_end)}px`,
|
||||||
|
}"
|
||||||
|
:title="`${task.subtask?.title || task.blocker?.type || 'Task'}`"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Unsaved timeblocks for same date -->
|
||||||
|
<div
|
||||||
|
v-for="(timeblock, index) in unsavedTimeblocksRendered"
|
||||||
|
:key="`unsaved-${index}`"
|
||||||
|
class="unsaved-timeblock"
|
||||||
|
:style="{
|
||||||
|
left: `${getTimePosition(timeblock._start)}px`,
|
||||||
|
width: `${getTimeWidth(timeblock._start, timeblock._end)}px`,
|
||||||
|
}"
|
||||||
|
:title="`Unsaved timeblock`"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Current timeblock (if valid) -->
|
||||||
|
<div
|
||||||
|
v-if="currentTimeblockRange.start && currentTimeblockRange.end"
|
||||||
|
class="current-timeblock"
|
||||||
|
:class="{ 'has-collision': collision.hasCollision }"
|
||||||
|
:style="{
|
||||||
|
left: `${getTimePosition(currentTimeblockRange.start)}px`,
|
||||||
|
width: `${getTimeWidth(
|
||||||
|
currentTimeblockRange.start,
|
||||||
|
currentTimeblockRange.end,
|
||||||
|
)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Duration display (centered in current timeblock) -->
|
||||||
|
<div v-if="timeblockDuration > 0" class="timeblock-duration">
|
||||||
|
{{ timeblockDuration.toFixed(2) }}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collision segments -->
|
||||||
|
<div
|
||||||
|
v-for="(segment, index) in collisionSegments"
|
||||||
|
:key="`collision-${index}`"
|
||||||
|
class="collision-segment"
|
||||||
|
:style="{
|
||||||
|
left: `${getTimePosition(segment.start)}px`,
|
||||||
|
width: `${getTimeWidth(segment.start, segment.end)}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import moment from 'moment';
|
||||||
|
import AEmployeeTimeblockTimelineHeader from './a-employee-timeblock-timeline-header.vue';
|
||||||
|
import type { EmployeeFullData, TimeblockData } from './types';
|
||||||
|
|
||||||
|
interface TimelineTask {
|
||||||
|
_real_start: Date;
|
||||||
|
_real_end: Date;
|
||||||
|
subtask?: { title: string };
|
||||||
|
blocker?: { type: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollisionSegment {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLoading: boolean;
|
||||||
|
employee: EmployeeFullData;
|
||||||
|
scheduledBlocks: TimelineTask[];
|
||||||
|
unsavedTimeblocks?: TimeblockData[];
|
||||||
|
currentTimeblockRange: {
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
};
|
||||||
|
collision: {
|
||||||
|
hasCollision: boolean;
|
||||||
|
startCollides: boolean;
|
||||||
|
endCollides: boolean;
|
||||||
|
};
|
||||||
|
collisionSegments: CollisionSegment[];
|
||||||
|
timeblockDuration: number;
|
||||||
|
startHour: number;
|
||||||
|
endHour: number;
|
||||||
|
hourWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
unsavedTimeblocks: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelineContainer = ref<HTMLElement | null>(null);
|
||||||
|
const timelineScroll = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Convert unsaved timeblocks to renderable format
|
||||||
|
const unsavedTimeblocksRendered = computed(() => {
|
||||||
|
return props.unsavedTimeblocks
|
||||||
|
.map((tb) => {
|
||||||
|
if (!tb.date || !tb.starts_at_time || !tb.ends_at_time) return null;
|
||||||
|
|
||||||
|
const [startHour, startMinute] = tb.starts_at_time.split(':').map(Number);
|
||||||
|
const [endHour, endMinute] = tb.ends_at_time.split(':').map(Number);
|
||||||
|
|
||||||
|
const start = moment(tb.date)
|
||||||
|
.set({ hour: startHour, minute: startMinute, second: 0 })
|
||||||
|
.toDate();
|
||||||
|
const end = moment(tb.date)
|
||||||
|
.set({ hour: endHour, minute: endMinute, second: 0 })
|
||||||
|
.toDate();
|
||||||
|
|
||||||
|
return { _start: start, _end: end };
|
||||||
|
})
|
||||||
|
.filter((tb) => tb !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position calculations
|
||||||
|
const getHourPosition = (hour: number): number => {
|
||||||
|
return (hour - props.startHour) * props.hourWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHourWidth = (hours: number): number => {
|
||||||
|
return hours * props.hourWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimePosition = (date: Date): number => {
|
||||||
|
const hour = date.getHours();
|
||||||
|
const minute = date.getMinutes();
|
||||||
|
const totalHours = hour + minute / 60;
|
||||||
|
return (totalHours - props.startHour) * props.hourWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeWidth = (start: Date, end: Date): number => {
|
||||||
|
const startHour = start.getHours() + start.getMinutes() / 60;
|
||||||
|
const endHour = end.getHours() + end.getMinutes() / 60;
|
||||||
|
return (endHour - startHour) * props.hourWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose refs for parent component
|
||||||
|
defineExpose({
|
||||||
|
timelineScroll,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.timeline
|
||||||
|
height: 98px
|
||||||
|
|
||||||
|
&__content
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
margin-bottom: 10px
|
||||||
|
|
||||||
|
&-loading
|
||||||
|
height: 100%
|
||||||
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
color: #666
|
||||||
|
|
||||||
|
.timeline-scroll
|
||||||
|
overflow-x: auto
|
||||||
|
overflow-y: hidden
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
scrollbar-width: none // Firefox
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
display: none // Chrome, Safari, Opera
|
||||||
|
|
||||||
|
.timeline-bar
|
||||||
|
position: relative
|
||||||
|
height: 60px
|
||||||
|
width: calc(24 * 60px)
|
||||||
|
background: #fafafa
|
||||||
|
border: 1px solid #e0e0e0
|
||||||
|
border-radius: 4px
|
||||||
|
|
||||||
|
@media (max-width: 768px)
|
||||||
|
width: calc(24 * 50px)
|
||||||
|
|
||||||
|
.non-working-hours
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
height: 100%
|
||||||
|
background: repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(224, 224, 224, 0.3) 10px, rgba(224, 224, 224, 0.3) 20px)
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
.work-hours-bar
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
height: 100%
|
||||||
|
background: transparent
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
.scheduled-block
|
||||||
|
position: absolute
|
||||||
|
top: 5px
|
||||||
|
height: 50px
|
||||||
|
background: #7f39fb
|
||||||
|
opacity: 0.6
|
||||||
|
border-radius: 2px
|
||||||
|
z-index: 2
|
||||||
|
|
||||||
|
.unsaved-timeblock
|
||||||
|
position: absolute
|
||||||
|
top: 5px
|
||||||
|
height: 50px
|
||||||
|
background: #FFA726
|
||||||
|
opacity: 0.5
|
||||||
|
border: 2px dashed #FF9800
|
||||||
|
border-radius: 2px
|
||||||
|
z-index: 2
|
||||||
|
|
||||||
|
.current-timeblock
|
||||||
|
position: absolute
|
||||||
|
top: 5px
|
||||||
|
height: 50px
|
||||||
|
background: #2196F3
|
||||||
|
opacity: 0.7
|
||||||
|
border: 2px solid #1976D2
|
||||||
|
border-radius: 2px
|
||||||
|
z-index: 3
|
||||||
|
|
||||||
|
&.has-collision
|
||||||
|
border-color: #ff5252
|
||||||
|
|
||||||
|
.collision-segment
|
||||||
|
position: absolute
|
||||||
|
top: 5px
|
||||||
|
height: 50px
|
||||||
|
background: #ff5252
|
||||||
|
opacity: 0.6
|
||||||
|
border-radius: 2px
|
||||||
|
z-index: 4
|
||||||
|
|
||||||
|
.timeblock-duration
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
left: 50%
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
font-size: 14px
|
||||||
|
font-weight: bold
|
||||||
|
color: white
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3)
|
||||||
|
pointer-events: none
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,221 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="timeblock">
|
<div class="timeblock">
|
||||||
<!-- Date and Time Controls -->
|
<a-employee-timeblock-header
|
||||||
<div class="timeblock__header">
|
:model-value="modelValue"
|
||||||
<div class="timeblock__date">
|
|
||||||
<button class="timeblock__date-button" @click="previousDay">
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Z-datepicker
|
|
||||||
:model-value="modelValue.date"
|
|
||||||
format="dd.MM.yyyy"
|
|
||||||
locale="sk"
|
|
||||||
select-text="Vybrať"
|
|
||||||
cancel-text="Zrušiť"
|
|
||||||
placeholder="Dátum"
|
|
||||||
:auto-apply="true"
|
|
||||||
:enable-time-picker="false"
|
|
||||||
:min-date="minDate"
|
:min-date="minDate"
|
||||||
class="timeblock__date-picker"
|
:deletable="deletable"
|
||||||
@update:model-value="updateDate"
|
: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"
|
||||||
|
@previous-day="previousDay"
|
||||||
|
@next-day="nextDay"
|
||||||
|
@update-date="updateDate"
|
||||||
|
@update-start-time="updateStartTime"
|
||||||
|
@update-end-time="updateEndTime"
|
||||||
|
@add-timeblock="$emit('addTimeblock')"
|
||||||
|
@remove-timeblock="$emit('removeTimeblock')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button class="timeblock__date-button" @click="nextDay">
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeblock__controls">
|
|
||||||
<v-text-field
|
|
||||||
:value="modelValue.starts_at_time"
|
|
||||||
:class="{
|
|
||||||
'error-input':
|
|
||||||
collision.startCollides ||
|
|
||||||
!isTimeRangeValid ||
|
|
||||||
(v$.$errors.length && v$.starts_at_time.$errors.length),
|
|
||||||
}"
|
|
||||||
type="time"
|
|
||||||
variant="solo"
|
|
||||||
bg-color="white"
|
|
||||||
label="Začiatok"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
@input="updateStartTime"
|
|
||||||
/>
|
|
||||||
<v-text-field
|
|
||||||
:value="modelValue.ends_at_time"
|
|
||||||
:class="{
|
|
||||||
'error-input':
|
|
||||||
collision.endCollides ||
|
|
||||||
!isTimeRangeValid ||
|
|
||||||
(v$.$errors.length && v$.ends_at_time.$errors.length),
|
|
||||||
}"
|
|
||||||
type="time"
|
|
||||||
variant="solo"
|
|
||||||
bg-color="white"
|
|
||||||
label="Koniec"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
@input="updateEndTime"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isTimeRangeValid" class="validation-error">
|
|
||||||
Koniec musí byť po začiatku
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button
|
|
||||||
v-if="deletable"
|
|
||||||
class="delete-btn"
|
|
||||||
@click="$emit('removeTimeblock')"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline Visualization -->
|
<!-- Timeline Visualization -->
|
||||||
<div v-if="modelValue.date && employee" class="timeline">
|
<a-employee-timeblock-timeline
|
||||||
<div v-if="isLoading" class="timeline-loading">
|
v-if="modelValue.date && employee"
|
||||||
<span>Načítavam...</span>
|
ref="timelineRef"
|
||||||
</div>
|
:is-loading="isLoading"
|
||||||
<div v-else class="timeline__content" ref="timelineContainer">
|
:employee="employee"
|
||||||
<div class="timeline-scroll" ref="timelineScroll">
|
:scheduled-blocks="scheduledBlocks"
|
||||||
<div class="timeline-hours">
|
:unsaved-timeblocks="unsavedTimeblocksForSameDate"
|
||||||
<div
|
:current-timeblock-range="currentTimeblockRange"
|
||||||
v-for="hour in hours"
|
:collision="collision"
|
||||||
:key="hour"
|
:collision-segments="collisionSegments"
|
||||||
class="hour-marker"
|
:timeblock-duration="timeblockDuration"
|
||||||
:style="{ left: `${getHourPosition(hour)}px` }"
|
:start-hour="START_HOUR"
|
||||||
>
|
:end-hour="END_HOUR"
|
||||||
<span class="hour-label">{{ hour }}:00</span>
|
:hour-width="hourWidth"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-bar">
|
|
||||||
<!-- Non-working hours - before work -->
|
|
||||||
<div
|
|
||||||
class="non-working-hours"
|
|
||||||
:style="{
|
|
||||||
left: '0px',
|
|
||||||
width: `${getHourPosition(employee.work_start)}px`,
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Non-working hours - after work -->
|
|
||||||
<div
|
|
||||||
class="non-working-hours"
|
|
||||||
:style="{
|
|
||||||
left: `${getHourPosition(employee.work_end)}px`,
|
|
||||||
width: `${getHourWidth(24 - employee.work_end)}px`,
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Work hours background (transparent/white) -->
|
|
||||||
<div
|
|
||||||
class="work-hours-bar"
|
|
||||||
:style="{
|
|
||||||
left: `${getHourPosition(employee.work_start)}px`,
|
|
||||||
width: `${getHourWidth(
|
|
||||||
employee.work_end - employee.work_start,
|
|
||||||
)}px`,
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Existing scheduled blocks -->
|
|
||||||
<div
|
|
||||||
v-for="(task, index) in scheduledBlocks"
|
|
||||||
:key="`task-${index}`"
|
|
||||||
class="scheduled-block"
|
|
||||||
:style="{
|
|
||||||
left: `${getTimePosition(task._real_start)}px`,
|
|
||||||
width: `${getTimeWidth(task._real_start, task._real_end)}px`,
|
|
||||||
}"
|
|
||||||
:title="`${task.subtask?.title || task.blocker?.type || 'Task'}`"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Current timeblock (if valid) -->
|
|
||||||
<div
|
|
||||||
v-if="currentTimeblockRange.start && currentTimeblockRange.end"
|
|
||||||
class="current-timeblock"
|
|
||||||
:class="{ 'has-collision': collision.hasCollision }"
|
|
||||||
:style="{
|
|
||||||
left: `${getTimePosition(currentTimeblockRange.start)}px`,
|
|
||||||
width: `${getTimeWidth(
|
|
||||||
currentTimeblockRange.start,
|
|
||||||
currentTimeblockRange.end,
|
|
||||||
)}px`,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Duration display (centered in current timeblock) -->
|
|
||||||
<div v-if="timeblockDuration > 0" class="timeblock-duration">
|
|
||||||
{{ timeblockDuration.toFixed(2) }}h
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collision segments -->
|
|
||||||
<div
|
|
||||||
v-for="(segment, index) in collisionSegments"
|
|
||||||
:key="`collision-${index}`"
|
|
||||||
class="collision-segment"
|
|
||||||
:style="{
|
|
||||||
left: `${getTimePosition(segment.start)}px`,
|
|
||||||
width: `${getTimeWidth(segment.start, segment.end)}px`,
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Time Statistics -->
|
|
||||||
<!-- <div class="time-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">Naplánované:</span>
|
|
||||||
<span class="stat-value">{{ scheduledTime }}h</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">Voľné:</span>
|
|
||||||
<span class="stat-value">{{ freeTime }}h</span>
|
|
||||||
</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 { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { 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 AEmployeeTimeblockHeader from './a-employee-timeblock-header.vue';
|
||||||
|
import AEmployeeTimeblockTimeline from './a-employee-timeblock-timeline.vue';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import type { TimeblockData, EmployeeFullData } from './types';
|
import type { TimeblockData, EmployeeFullData } from './types';
|
||||||
|
|
||||||
|
// Configuration: Set to false to allow editing timeblocks with existing IDs
|
||||||
|
const ALLOW_EDITING_EXISTING_TIMEBLOCKS = false;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: TimeblockData;
|
modelValue: TimeblockData;
|
||||||
employee: EmployeeFullData | null;
|
employee: EmployeeFullData | null;
|
||||||
|
allEmployeeTimeblocks?: TimeblockData[];
|
||||||
|
currentIndex?: number;
|
||||||
deletable?: boolean;
|
deletable?: boolean;
|
||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
allEmployeeTimeblocks: () => [],
|
||||||
|
currentIndex: 0,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: TimeblockData): void;
|
(e: 'update:modelValue', value: TimeblockData): void;
|
||||||
|
(e: 'addTimeblock'): void;
|
||||||
(e: 'removeTimeblock'): void;
|
(e: 'removeTimeblock'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const timelineContainer = ref<HTMLElement | null>(null);
|
const timelineRef = ref<InstanceType<typeof AEmployeeTimeblockTimeline> | null>(
|
||||||
const timelineScroll = ref<HTMLElement | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if timeblock is editable (based on whether it has an ID)
|
||||||
|
const isEditable = computed(() => {
|
||||||
|
if (ALLOW_EDITING_EXISTING_TIMEBLOCKS) return true;
|
||||||
|
return !props.modelValue.id;
|
||||||
|
});
|
||||||
|
|
||||||
// Minimum date is today (no past dates)
|
// Minimum date is today (no past dates)
|
||||||
const minDate = ref(new Date());
|
const minDate = ref(new Date());
|
||||||
@@ -244,23 +111,23 @@ const hourWidth = computed(() =>
|
|||||||
isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH,
|
isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate hours array
|
// Check if timeblock has partial data (any field filled)
|
||||||
const hours = computed(() => {
|
const hasPartialData = computed(() => {
|
||||||
const result = [];
|
return !!(
|
||||||
for (let i = START_HOUR; i <= END_HOUR; i++) {
|
props.modelValue.date ||
|
||||||
result.push(i);
|
props.modelValue.starts_at_time ||
|
||||||
}
|
props.modelValue.ends_at_time
|
||||||
return result;
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vuelidate rules
|
// Vuelidate rules - only validate if timeblock has partial data
|
||||||
const rules = {
|
const rules = {
|
||||||
date: {},
|
date: {},
|
||||||
starts_at_time: {
|
starts_at_time: {
|
||||||
required: requiredIf(() => !!props.modelValue.date),
|
required: requiredIf(hasPartialData),
|
||||||
},
|
},
|
||||||
ends_at_time: {
|
ends_at_time: {
|
||||||
required: requiredIf(() => !!props.modelValue.date),
|
required: requiredIf(hasPartialData),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,8 +142,8 @@ const timeblockRef = computed(() => props.modelValue);
|
|||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
timelineTasks,
|
timelineTasks,
|
||||||
scheduledTime,
|
scheduledTime: _scheduledTime,
|
||||||
freeTime,
|
freeTime: _freeTime,
|
||||||
collision,
|
collision,
|
||||||
collisionSegments,
|
collisionSegments,
|
||||||
timeblockDuration,
|
timeblockDuration,
|
||||||
@@ -298,6 +165,29 @@ const scheduledBlocks = computed(() => {
|
|||||||
return timelineTasks.value;
|
return timelineTasks.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get other unsaved timeblocks for the same date (excluding current one)
|
||||||
|
const unsavedTimeblocksForSameDate = computed(() => {
|
||||||
|
if (!props.modelValue.date || !props.allEmployeeTimeblocks) return [];
|
||||||
|
|
||||||
|
const currentDate = moment(props.modelValue.date).format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
return props.allEmployeeTimeblocks
|
||||||
|
.filter((tb, index) => {
|
||||||
|
// Exclude the current timeblock
|
||||||
|
if (index === props.currentIndex) return false;
|
||||||
|
|
||||||
|
// Only include timeblocks without IDs (unsaved) or with the same date
|
||||||
|
if (!tb.date) return false;
|
||||||
|
|
||||||
|
const tbDate = moment(tb.date).format('YYYY-MM-DD');
|
||||||
|
return tbDate === currentDate;
|
||||||
|
})
|
||||||
|
.filter((tb) => {
|
||||||
|
// Only include if it has valid time range
|
||||||
|
return tb.starts_at_time && tb.ends_at_time;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Get current timeblock range
|
// Get current timeblock range
|
||||||
const currentTimeblockRange = computed(() => {
|
const currentTimeblockRange = computed(() => {
|
||||||
const { start, end } = timeblockToDateRange(props.modelValue);
|
const { start, end } = timeblockToDateRange(props.modelValue);
|
||||||
@@ -314,42 +204,25 @@ const isTimeRangeValid = computed(() => {
|
|||||||
return end > start;
|
return end > start;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Position calculations
|
// Center timeline around middle of work hours
|
||||||
const getHourPosition = (hour: number): number => {
|
|
||||||
return (hour - START_HOUR) * hourWidth.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHourWidth = (hours: number): number => {
|
|
||||||
return hours * hourWidth.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTimePosition = (date: Date): number => {
|
|
||||||
const hour = date.getHours();
|
|
||||||
const minute = date.getMinutes();
|
|
||||||
const totalHours = hour + minute / 60;
|
|
||||||
return (totalHours - START_HOUR) * hourWidth.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTimeWidth = (start: Date, end: Date): number => {
|
|
||||||
const startHour = start.getHours() + start.getMinutes() / 60;
|
|
||||||
const endHour = end.getHours() + end.getMinutes() / 60;
|
|
||||||
return (endHour - startHour) * hourWidth.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Center timeline around noon/1pm
|
|
||||||
const centerTimeline = () => {
|
const centerTimeline = () => {
|
||||||
if (!timelineScroll.value) return;
|
if (!timelineRef.value?.timelineScroll) return;
|
||||||
|
|
||||||
const centerHour = isMobile.value ? 12.5 : 12; // Center around 12:30 on mobile, 12:00 on desktop
|
// Calculate the middle of work hours
|
||||||
const centerPosition = getHourPosition(centerHour);
|
const workStart = props.employee?.work_start ?? 8;
|
||||||
const containerWidth = timelineScroll.value.clientWidth;
|
const workEnd = props.employee?.work_end ?? 17;
|
||||||
|
const centerHour = (workStart + workEnd) / 2;
|
||||||
|
|
||||||
timelineScroll.value.scrollLeft = centerPosition - containerWidth / 2;
|
const centerPosition = (centerHour - START_HOUR) * hourWidth.value;
|
||||||
|
const containerWidth = timelineRef.value.timelineScroll.clientWidth;
|
||||||
|
|
||||||
|
timelineRef.value.timelineScroll.scrollLeft =
|
||||||
|
centerPosition - containerWidth / 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for date changes to re-center
|
// Watch for date changes or employee changes to re-center
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue.date,
|
() => [props.modelValue.date, props.employee?.id],
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
centerTimeline();
|
centerTimeline();
|
||||||
@@ -357,6 +230,18 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Watch for timeline ref to become available and center it
|
||||||
|
watch(
|
||||||
|
timelineRef,
|
||||||
|
(newRef) => {
|
||||||
|
if (newRef?.timelineScroll) {
|
||||||
|
nextTick(() => {
|
||||||
|
centerTimeline();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Date navigation
|
// Date navigation
|
||||||
const previousDay = () => {
|
const previousDay = () => {
|
||||||
if (!props.modelValue.date) return;
|
if (!props.modelValue.date) return;
|
||||||
@@ -420,266 +305,4 @@ defineExpose({
|
|||||||
border: 1px solid #e5e5e5
|
border: 1px solid #e5e5e5
|
||||||
border-radius: 8px
|
border-radius: 8px
|
||||||
margin-bottom: 8px
|
margin-bottom: 8px
|
||||||
|
|
||||||
&__header
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 15px
|
|
||||||
flex-wrap: wrap
|
|
||||||
border-bottom: 1px solid #e5e5e5
|
|
||||||
padding: 8px 16px
|
|
||||||
|
|
||||||
@media (max-width: 768px)
|
|
||||||
flex-direction: column
|
|
||||||
align-items: stretch
|
|
||||||
|
|
||||||
&__date
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
flex: 1
|
|
||||||
max-width: 220px
|
|
||||||
// gap: 16px
|
|
||||||
|
|
||||||
&__date-button
|
|
||||||
background: #f5f5f5
|
|
||||||
border: 1px solid #e0e0e0
|
|
||||||
border-radius: 4px
|
|
||||||
width: 44px
|
|
||||||
height: 44px
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
justify-content: center
|
|
||||||
cursor: pointer
|
|
||||||
transition: all 0.2s
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: #e0e0e0
|
|
||||||
|
|
||||||
&:active
|
|
||||||
background: #d0d0d0
|
|
||||||
|
|
||||||
&:first-child
|
|
||||||
border-top-right-radius: 0
|
|
||||||
border-bottom-right-radius: 0
|
|
||||||
border-right: none
|
|
||||||
|
|
||||||
&: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
|
|
||||||
gap: 10px
|
|
||||||
flex: 1
|
|
||||||
|
|
||||||
@media (max-width: 768px)
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
::v-deep .v-input
|
|
||||||
width: 140px !important
|
|
||||||
flex-grow: 0
|
|
||||||
|
|
||||||
.error-input
|
|
||||||
::v-deep input
|
|
||||||
border-bottom: 2px solid #ff5252 !important
|
|
||||||
|
|
||||||
.validation-error
|
|
||||||
color: #ff5252
|
|
||||||
font-size: 12px
|
|
||||||
margin-top: 5px
|
|
||||||
padding-left: 10px
|
|
||||||
|
|
||||||
.actions
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
|
|
||||||
.delete-btn
|
|
||||||
background: #ff5252
|
|
||||||
color: white
|
|
||||||
border: none
|
|
||||||
border-radius: 4px
|
|
||||||
width: 44px
|
|
||||||
height: 44px
|
|
||||||
font-size: 28px
|
|
||||||
cursor: pointer
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
justify-content: center
|
|
||||||
transition: all 0.2s
|
|
||||||
line-height: 1
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: #ff1744
|
|
||||||
|
|
||||||
.timeline
|
|
||||||
height: 98px
|
|
||||||
|
|
||||||
&__content
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
margin-bottom: 10px
|
|
||||||
|
|
||||||
&__loading
|
|
||||||
height: 100%
|
|
||||||
width: 100%
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
justify-content: center
|
|
||||||
color: #666
|
|
||||||
|
|
||||||
.timeline-scroll
|
|
||||||
overflow-x: auto
|
|
||||||
overflow-y: hidden
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
&::-webkit-scrollbar
|
|
||||||
height: 8px
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track
|
|
||||||
background: #f1f1f1
|
|
||||||
border-radius: 4px
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb
|
|
||||||
background: #888
|
|
||||||
border-radius: 4px
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: #555
|
|
||||||
|
|
||||||
.timeline-hours
|
|
||||||
position: relative
|
|
||||||
height: 30px
|
|
||||||
width: calc(24 * 60px)
|
|
||||||
|
|
||||||
@media (max-width: 768px)
|
|
||||||
width: calc(24 * 50px)
|
|
||||||
|
|
||||||
.hour-marker
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
height: 30px
|
|
||||||
border-left: 1px solid #e0e0e0
|
|
||||||
|
|
||||||
.hour-label
|
|
||||||
position: absolute
|
|
||||||
top: 7px
|
|
||||||
left: 5px
|
|
||||||
font-size: 11px
|
|
||||||
color: #666
|
|
||||||
white-space: nowrap
|
|
||||||
|
|
||||||
.timeline-bar
|
|
||||||
position: relative
|
|
||||||
height: 60px
|
|
||||||
width: calc(24 * 60px)
|
|
||||||
background: #fafafa
|
|
||||||
border: 1px solid #e0e0e0
|
|
||||||
border-radius: 4px
|
|
||||||
|
|
||||||
@media (max-width: 768px)
|
|
||||||
width: calc(24 * 50px)
|
|
||||||
|
|
||||||
.non-working-hours
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
height: 100%
|
|
||||||
background: #e0e0e0
|
|
||||||
opacity: 0.5
|
|
||||||
z-index: 1
|
|
||||||
|
|
||||||
.work-hours-bar
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
height: 100%
|
|
||||||
background: transparent
|
|
||||||
z-index: 1
|
|
||||||
|
|
||||||
.scheduled-block
|
|
||||||
position: absolute
|
|
||||||
top: 5px
|
|
||||||
height: 50px
|
|
||||||
background: #7f39fb
|
|
||||||
opacity: 0.6
|
|
||||||
border-radius: 2px
|
|
||||||
z-index: 2
|
|
||||||
|
|
||||||
.current-timeblock
|
|
||||||
position: absolute
|
|
||||||
top: 5px
|
|
||||||
height: 50px
|
|
||||||
background: #2196F3
|
|
||||||
opacity: 0.7
|
|
||||||
border: 2px solid #1976D2
|
|
||||||
border-radius: 2px
|
|
||||||
z-index: 3
|
|
||||||
|
|
||||||
&.has-collision
|
|
||||||
border-color: #ff5252
|
|
||||||
|
|
||||||
.collision-segment
|
|
||||||
position: absolute
|
|
||||||
top: 5px
|
|
||||||
height: 50px
|
|
||||||
background: #ff5252
|
|
||||||
opacity: 0.6
|
|
||||||
border-radius: 2px
|
|
||||||
z-index: 4
|
|
||||||
|
|
||||||
.timeblock-duration
|
|
||||||
position: absolute
|
|
||||||
top: 50%
|
|
||||||
left: 50%
|
|
||||||
transform: translate(-50%, -50%)
|
|
||||||
font-size: 14px
|
|
||||||
font-weight: bold
|
|
||||||
color: white
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3)
|
|
||||||
pointer-events: none
|
|
||||||
|
|
||||||
.time-stats
|
|
||||||
display: flex
|
|
||||||
gap: 20px
|
|
||||||
justify-content: flex-end
|
|
||||||
padding: 10px
|
|
||||||
background: #f5f5f5
|
|
||||||
border-radius: 4px
|
|
||||||
|
|
||||||
@media (max-width: 768px)
|
|
||||||
justify-content: space-around
|
|
||||||
|
|
||||||
.stat-item
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
align-items: center
|
|
||||||
|
|
||||||
.stat-label
|
|
||||||
font-size: 12px
|
|
||||||
color: #666
|
|
||||||
margin-bottom: 4px
|
|
||||||
|
|
||||||
.stat-value
|
|
||||||
font-size: 18px
|
|
||||||
font-weight: bold
|
|
||||||
color: #7f39fb
|
|
||||||
</style>
|
</style>
|
||||||
@@ -15,24 +15,20 @@
|
|||||||
:ref="(el) => setTimeblockRef(el, index)"
|
:ref="(el) => setTimeblockRef(el, index)"
|
||||||
:model-value="timeblock"
|
:model-value="timeblock"
|
||||||
:employee="selectedEmployee"
|
:employee="selectedEmployee"
|
||||||
|
:all-employee-timeblocks="timeblocks"
|
||||||
|
:current-index="index"
|
||||||
:deletable="timeblocks.length > 1"
|
:deletable="timeblocks.length > 1"
|
||||||
:index="index"
|
:index="index"
|
||||||
@update:model-value="(val) => handleUpdateTimeblock(index, val)"
|
@update:model-value="(val) => handleUpdateTimeblock(index, val)"
|
||||||
|
@add-timeblock="handleAddTimeblock(index)"
|
||||||
@remove-timeblock="handleRemoveTimeblock(index)"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch, nextTick } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import AEmployeeSelect from './a-employee-select.vue';
|
import AEmployeeSelect from './a-employee-select.vue';
|
||||||
import AEmployeeTimeblock from './a-employee-timeblock.vue';
|
import AEmployeeTimeblock from './a-employee-timeblock.vue';
|
||||||
@@ -59,7 +55,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:employeeData', value: EmployeeData): void;
|
(e: 'update:employeeData', value: EmployeeData): void;
|
||||||
(e: 'update:timeblocks', value: TimeblockData[]): void;
|
(e: 'update:timeblocks', value: TimeblockData[]): void;
|
||||||
(e: 'clearEmployee'): void;
|
(e: 'clearEmployee'): void;
|
||||||
(e: 'addTimeblock'): void;
|
(e: 'addTimeblock', afterIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -89,6 +85,19 @@ const selectedEmployee = computed(() => {
|
|||||||
return employees.find((emp: any) => emp.id === props.employeeData.id) || null;
|
return employees.find((emp: any) => emp.id === props.employeeData.id) || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
nextTick(() => {
|
||||||
|
emit('addTimeblock', -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleClearEmployee = () => {
|
const handleClearEmployee = () => {
|
||||||
emit('clearEmployee');
|
emit('clearEmployee');
|
||||||
};
|
};
|
||||||
@@ -105,8 +114,8 @@ const handleRemoveTimeblock = (index: number) => {
|
|||||||
emit('update:timeblocks', updatedTimeblocks);
|
emit('update:timeblocks', updatedTimeblocks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTimeblock = () => {
|
const handleAddTimeblock = (afterIndex: number) => {
|
||||||
emit('addTimeblock');
|
emit('addTimeblock', afterIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose validate method that validates all child components
|
// Expose validate method that validates all child components
|
||||||
@@ -145,22 +154,8 @@ defineExpose({
|
|||||||
padding: 16px
|
padding: 16px
|
||||||
border-radius: 8px
|
border-radius: 8px
|
||||||
|
|
||||||
.add-timeblock-btn-wrapper
|
&__timeblocks
|
||||||
width: 100%
|
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: flex-end
|
flex-direction: column
|
||||||
margin-top: 15px
|
gap: 8px
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
<div class="splitted-inputs">
|
<div class="splitted-inputs">
|
||||||
<!-- TODO: Bialko validacie -->
|
<!-- TODO: Bialko validacie -->
|
||||||
<div class="w-100 modal-input">
|
<div class="left">
|
||||||
<Z-datepicker
|
<Z-datepicker
|
||||||
ref="datepicker-01"
|
ref="datepicker-01"
|
||||||
v-model="subtask.deadline"
|
v-model="subtask.deadline"
|
||||||
@@ -99,20 +99,25 @@
|
|||||||
updateEmployeeTimeblocks($event, subtask.employee_id)
|
updateEmployeeTimeblocks($event, subtask.employee_id)
|
||||||
"
|
"
|
||||||
@clear-employee="handleRemoveEmployee"
|
@clear-employee="handleRemoveEmployee"
|
||||||
@add-timeblock="addTimeblock(subtask.employee_id)"
|
@add-timeblock="
|
||||||
|
(afterIndex) => addTimeblock(subtask.employee_id, afterIndex)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Create Mode: Multiple Employees -->
|
<!-- Create Mode: Multiple Employees -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<A-employee
|
<A-employee
|
||||||
v-for="(employee, index) in getValidEmployees()"
|
v-for="(employee, index) in subtask.employees"
|
||||||
:key="`employee-${employee.id || index}`"
|
:key="`employee-${employee.id || index}`"
|
||||||
|
:ref="(el) => setEmployeeRef(el, index)"
|
||||||
v-model:employee-data="subtask.employees[index]"
|
v-model:employee-data="subtask.employees[index]"
|
||||||
:timeblocks="getEmployeeTimeblocks(employee.id)"
|
:timeblocks="getEmployeeTimeblocks(employee.id)"
|
||||||
:custom-filter="customFilter"
|
:custom-filter="customFilter"
|
||||||
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
|
@update:timeblocks="updateEmployeeTimeblocks($event, employee.id)"
|
||||||
@clear-employee="() => removeEmployeeAtIndex(index)"
|
@clear-employee="() => removeEmployeeAtIndex(index)"
|
||||||
@add-timeblock="addTimeblock(employee.id)"
|
@add-timeblock="
|
||||||
|
(afterIndex) => addTimeblock(employee.id, afterIndex)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -296,12 +301,12 @@ export default {
|
|||||||
isSaving: false,
|
isSaving: false,
|
||||||
errorUtils,
|
errorUtils,
|
||||||
v$: useVuelidate(),
|
v$: useVuelidate(),
|
||||||
employeeValidations: [],
|
|
||||||
showDatePicker: false,
|
showDatePicker: false,
|
||||||
selectedNewDate: [],
|
selectedNewDate: [],
|
||||||
timeblocks: [],
|
timeblocks: [],
|
||||||
wasFormValidationTriggered: false,
|
wasFormValidationTriggered: false,
|
||||||
modalName: 'z-task-modal',
|
modalName: 'z-task-modal',
|
||||||
|
employeeRefs: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -490,6 +495,13 @@ export default {
|
|||||||
this.editMode = !this.editMode;
|
this.editMode = !this.editMode;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set refs for employee components in create mode
|
||||||
|
setEmployeeRef(el, index) {
|
||||||
|
if (el) {
|
||||||
|
this.employeeRefs[index] = el;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
openDatepicker(ref) {
|
openDatepicker(ref) {
|
||||||
this.$refs[ref].openMenu();
|
this.$refs[ref].openMenu();
|
||||||
},
|
},
|
||||||
@@ -530,17 +542,6 @@ export default {
|
|||||||
this.timeblocks = [...otherTimeblocks, ...timeblocksWithEmployeeId];
|
this.timeblocks = [...otherTimeblocks, ...timeblocksWithEmployeeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear employee validation
|
|
||||||
clearEmployeeValidation(index) {
|
|
||||||
if (index !== undefined && this.employeeValidations[index]) {
|
|
||||||
this.employeeValidations[index].employee = false;
|
|
||||||
this.employeeValidations[index].duration = false;
|
|
||||||
} else if (index === undefined && this.employeeValidations[0]) {
|
|
||||||
this.employeeValidations[0].employee = false;
|
|
||||||
this.employeeValidations[0].duration = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTimeblock(val, index, employeeId) {
|
updateTimeblock(val, index, employeeId) {
|
||||||
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
|
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
|
||||||
const timeblock = employeeTimeblocks[index];
|
const timeblock = employeeTimeblocks[index];
|
||||||
@@ -555,7 +556,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addTimeblock(employeeId) {
|
addTimeblock(employeeId, afterIndex = -1) {
|
||||||
const newTimeblock = {
|
const newTimeblock = {
|
||||||
date: new Date(), // Preset to current day
|
date: new Date(), // Preset to current day
|
||||||
starts_at_time: null,
|
starts_at_time: null,
|
||||||
@@ -563,7 +564,21 @@ export default {
|
|||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get timeblocks for this employee
|
||||||
|
const employeeTimeblocks = this.timeblocks.filter(
|
||||||
|
(tb) => tb.employee_id === employeeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (afterIndex === -1 || afterIndex >= employeeTimeblocks.length - 1) {
|
||||||
|
// Add at the end
|
||||||
this.timeblocks.push(newTimeblock);
|
this.timeblocks.push(newTimeblock);
|
||||||
|
} else {
|
||||||
|
// Find the position in the full timeblocks array
|
||||||
|
const timeblockToInsertAfter = employeeTimeblocks[afterIndex];
|
||||||
|
const insertPosition =
|
||||||
|
this.timeblocks.indexOf(timeblockToInsertAfter) + 1;
|
||||||
|
this.timeblocks.splice(insertPosition, 0, newTimeblock);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeTimeblock(index, employeeId) {
|
removeTimeblock(index, employeeId) {
|
||||||
@@ -628,6 +643,7 @@ export default {
|
|||||||
|
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
this.timeblocks = [];
|
this.timeblocks = [];
|
||||||
|
this.employeeRefs = [];
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.v$.$reset();
|
this.v$.$reset();
|
||||||
|
|
||||||
@@ -922,55 +938,42 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_validateEmployees() {
|
async _validateEmployees() {
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.employeeValidations = [];
|
|
||||||
|
|
||||||
if (this.subtask.id) {
|
if (this.subtask.id) {
|
||||||
// For existing subtasks, require both employee and duration
|
// For existing subtasks, at least one employee is required
|
||||||
if (!this.subtask.employee_id || !this.subtask.duration) {
|
// Component-level validation will handle the conditional validation
|
||||||
this.employeeValidations[0] = {
|
const hasEmployee = !!(
|
||||||
employee: !this.subtask.employee_id,
|
this.subtask.employee_id && this.subtask.duration
|
||||||
duration: !this.subtask.duration,
|
);
|
||||||
};
|
if (!hasEmployee) {
|
||||||
|
this.$toast.error('Je potrebné vybrať zamestnanca a trvanie');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For new subtasks, require at least one employee with duration
|
// For new subtasks, require at least one complete employee (id + duration)
|
||||||
let hasValidEmployee = false;
|
const validEmployees = this.subtask.employees.filter(
|
||||||
|
(employee) => employee.id && employee.duration,
|
||||||
this.subtask.employees.forEach((employee, index) => {
|
|
||||||
// Skip validation if both id and duration are empty
|
|
||||||
if (!employee.id && !employee.duration) {
|
|
||||||
// If this is the first employee and no valid employee exists yet, show validation
|
|
||||||
if (index === 0 && !hasValidEmployee) {
|
|
||||||
this.employeeValidations[index] = {
|
|
||||||
employee: true,
|
|
||||||
duration: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate if either id or duration is filled
|
|
||||||
if (employee.id || employee.duration) {
|
|
||||||
this.employeeValidations[index] = {
|
|
||||||
employee: !employee.id,
|
|
||||||
duration: !employee.duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If both id and duration are present, mark as valid
|
|
||||||
if (employee.id && employee.duration) {
|
|
||||||
hasValidEmployee = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isError = this.employeeValidations.some(
|
|
||||||
(employee) => employee.duration || employee.employee,
|
|
||||||
);
|
);
|
||||||
resolve(!isError);
|
|
||||||
});
|
if (validEmployees.length === 0) {
|
||||||
|
this.$toast.error(
|
||||||
|
'Je potrebné pridať aspoň jedného zamestnanca s trvaním',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all employee component refs (includes employee select and timeblocks validation)
|
||||||
|
for (const employeeRef of this.employeeRefs) {
|
||||||
|
if (employeeRef && typeof employeeRef.validate === 'function') {
|
||||||
|
const isValid = await employeeRef.validate();
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async _validateTimeblocks() {
|
async _validateTimeblocks() {
|
||||||
@@ -1046,6 +1049,18 @@ export default {
|
|||||||
.border-bottom
|
.border-bottom
|
||||||
border-bottom: 1px solid rgba(0,0,0,.1)
|
border-bottom: 1px solid rgba(0,0,0,.1)
|
||||||
|
|
||||||
|
.splitted-inputs
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
gap: 16px
|
||||||
|
|
||||||
|
.left
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
.right ::v-deep
|
||||||
|
.v-field__field
|
||||||
|
height: 56px !important
|
||||||
|
|
||||||
.done-btn
|
.done-btn
|
||||||
background: #7f39fb !important
|
background: #7f39fb !important
|
||||||
border-radius: 4px !important
|
border-radius: 4px !important
|
||||||
@@ -1053,111 +1068,10 @@ export default {
|
|||||||
height: 50px
|
height: 50px
|
||||||
width: 203px
|
width: 203px
|
||||||
|
|
||||||
.remove-employee
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
svg
|
|
||||||
position: absolute
|
|
||||||
right: 5px
|
|
||||||
top: 29px
|
|
||||||
z-index: 5
|
|
||||||
opacity: .5
|
|
||||||
|
|
||||||
&.hide-arrow ::v-deep
|
|
||||||
.mdi-menu-down,
|
|
||||||
.mdi-menu-up
|
|
||||||
display: none
|
|
||||||
|
|
||||||
.employee-wrapper
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
svg
|
|
||||||
position: absolute
|
|
||||||
right: 4px
|
|
||||||
top: 28px
|
|
||||||
z-index: 5
|
|
||||||
opacity: .5
|
|
||||||
|
|
||||||
&.hide-arrow ::v-deep
|
|
||||||
.mdi-menu-down,
|
|
||||||
.mdi-menu-up
|
|
||||||
display: none
|
|
||||||
|
|
||||||
@media screen and (max-width: 522px)
|
@media screen and (max-width: 522px)
|
||||||
.files
|
.files
|
||||||
margin-top: 104px !important
|
margin-top: 104px !important
|
||||||
|
|
||||||
.employees-timeblocks
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
width: 100%
|
|
||||||
margin-top: 20px
|
|
||||||
|
|
||||||
.employee-timeblocks-section
|
|
||||||
width: 100%
|
|
||||||
margin-bottom: 30px
|
|
||||||
border: 1px solid #e0e0e0
|
|
||||||
border-radius: 8px
|
|
||||||
padding: 20px
|
|
||||||
background: #fafafa
|
|
||||||
|
|
||||||
.employee-header
|
|
||||||
margin-bottom: 15px
|
|
||||||
padding-bottom: 10px
|
|
||||||
border-bottom: 2px solid #7f39fb
|
|
||||||
|
|
||||||
h3
|
|
||||||
margin: 0
|
|
||||||
color: #7f39fb
|
|
||||||
font-size: 18px
|
|
||||||
font-weight: 600
|
|
||||||
|
|
||||||
.end-button
|
|
||||||
width: 100%
|
|
||||||
display: flex
|
|
||||||
justify-content: flex-end
|
|
||||||
align-items: center
|
|
||||||
margin-top: 20px
|
|
||||||
padding-top: 20px
|
|
||||||
border-top: 1px solid #e0e0e0
|
|
||||||
|
|
||||||
button
|
|
||||||
background: #7f39fb !important
|
|
||||||
border-radius: 4px !important
|
|
||||||
color: #fff !important
|
|
||||||
height: 50px
|
|
||||||
width: 203px
|
|
||||||
border: none
|
|
||||||
cursor: pointer
|
|
||||||
transition: all 0.2s
|
|
||||||
font-size: 14px
|
|
||||||
font-weight: 500
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: #6b2dd9 !important
|
|
||||||
|
|
||||||
&:active
|
|
||||||
background: #5a24b8 !important
|
|
||||||
|
|
||||||
.timeblock
|
|
||||||
position: relative
|
|
||||||
border-top: 1px solid #e0e0e0
|
|
||||||
padding: 20px 0 10px 0
|
|
||||||
|
|
||||||
.splitted-time-inputs
|
|
||||||
display: flex !important
|
|
||||||
flex-direction: row !important
|
|
||||||
gap: 15px
|
|
||||||
margin-bottom: -11px
|
|
||||||
|
|
||||||
.datepicker-wrapper
|
|
||||||
width: 70%
|
|
||||||
.datepicker-input
|
|
||||||
width: 100% !important
|
|
||||||
pointer-events: none
|
|
||||||
|
|
||||||
.quill-editor-wrapper
|
.quill-editor-wrapper
|
||||||
:deep(.ql-container)
|
:deep(.ql-container)
|
||||||
min-height: 200px
|
min-height: 200px
|
||||||
|
|||||||
Reference in new issue
Block a user