wip(task-timeline): stage changes

This commit is contained in:
Matej Glasnák committed 2025-12-07 22:36:57 +01:00
1 parent 4768986a25
commit b1771ead05
11 files changed
+2220 -682

No files matched your search

@@ -0,0 +1,627 @@
<template>
<div class="timeblock-container">
<!-- Date and Time Controls -->
<div class="timeblock-header">
<div class="date-navigation">
<button class="nav-btn" @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="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"
:min-date="minDate"
class="date-picker-input"
@update:model-value="updateDate"
/>
<button class="nav-btn" @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="time-controls">
<v-text-field
:value="timeblock.starts_at_time"
:class="{
'error-input': collision.startCollides || !isTimeRangeValid,
}"
type="time"
variant="solo"
bg-color="white"
label="Začiatok"
density="compact"
hide-details
@input="updateStartTime"
/>
<v-text-field
:value="timeblock.ends_at_time"
:class="{ 'error-input': collision.endCollides || !isTimeRangeValid }"
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 -->
<div v-if="timeblock.date && employee" class="timeline-wrapper">
<div v-if="isLoading" class="timeline-loading">
<span>Načítavam...</span>
</div>
<div v-else class="timeline-container" ref="timelineContainer">
<div class="timeline-scroll" ref="timelineScroll">
<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>
<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>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { useEmployeeTimeline } from '@/plugins/tm@_utils/useEmployeeTimeline';
import { timeblockToDateRange } from '@/plugins/tm@_utils/timeblock.utils';
import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
import moment from 'moment';
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 {
timeblock: Timeblock;
employee: Employee | null;
deletable?: boolean;
index?: number;
}
const props = withDefaults(defineProps<Props>(), {
deletable: false,
index: 0,
});
const emit = defineEmits<{
(e: 'updateTimeblock', value: Timeblock): void;
(e: 'removeTimeblock'): void;
}>();
const timelineContainer = ref<HTMLElement | null>(null);
const timelineScroll = ref<HTMLElement | null>(null);
// Minimum date is today (no past dates)
const minDate = ref(new Date());
// Timeline configuration
const HOUR_WIDTH = 60; // pixels per hour (desktop)
const HOUR_WIDTH_MOBILE = 50; // pixels per hour (mobile)
const START_HOUR = 0; // Start from midnight
const END_HOUR = 24; // End at midnight
// Check if mobile
const isMobile = ref(false);
onMounted(() => {
isMobile.value = window.innerWidth <= 768;
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768;
});
// Center timeline on mount
nextTick(() => {
centerTimeline();
});
});
const hourWidth = computed(() =>
isMobile.value ? HOUR_WIDTH_MOBILE : HOUR_WIDTH,
);
// Generate hours array
const hours = computed(() => {
const result = [];
for (let i = START_HOUR; i <= END_HOUR; i++) {
result.push(i);
}
return result;
});
// Reactive refs for composable
const employeeRef = computed(() => props.employee);
const dateRef = computed(() => props.timeblock.date);
const timeblockRef = computed(() => props.timeblock);
// Use timeline composable
const {
isLoading,
timelineTasks,
scheduledTime,
freeTime,
collision,
collisionSegments,
timeblockDuration,
refetch,
} = useEmployeeTimeline(employeeRef, dateRef, timeblockRef);
// Force refetch when date changes
watch(
() => props.timeblock.date,
() => {
if (props.timeblock.date && props.employee?.id) {
refetch();
}
},
);
// All tasks from timelineTasks are already filtered for the selected date
const scheduledBlocks = computed(() => {
return timelineTasks.value;
});
// Get current timeblock range
const currentTimeblockRange = computed(() => {
const { start, end } = timeblockToDateRange(props.timeblock);
return { start, end };
});
// Validate that end time is after start time
const isTimeRangeValid = computed(() => {
if (!props.timeblock.starts_at_time || !props.timeblock.ends_at_time) {
return true; // Don't show error if fields are empty
}
const { start, end } = currentTimeblockRange.value;
if (!start || !end) return true;
return end > start;
});
// Position calculations
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 = () => {
if (!timelineScroll.value) return;
const centerHour = isMobile.value ? 12.5 : 12; // Center around 12:30 on mobile, 12:00 on desktop
const centerPosition = getHourPosition(centerHour);
const containerWidth = timelineScroll.value.clientWidth;
timelineScroll.value.scrollLeft = centerPosition - containerWidth / 2;
};
// Watch for date changes to re-center
watch(
() => props.timeblock.date,
() => {
nextTick(() => {
centerTimeline();
});
},
);
// Date navigation
const previousDay = () => {
if (!props.timeblock.date) return;
const currentDate = moment(props.timeblock.date);
const newDate = currentDate.subtract(1, 'day').toDate();
// Don't allow going to past dates
if (moment(newDate).isBefore(moment(), 'day')) return;
updateDate(newDate);
};
const nextDay = () => {
if (!props.timeblock.date) return;
const currentDate = moment(props.timeblock.date);
const newDate = currentDate.add(1, 'day').toDate();
updateDate(newDate);
};
// Update handlers
const updateDate = (date: Date | null) => {
emit('updateTimeblock', { ...props.timeblock, date });
};
const updateStartTime = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('updateTimeblock', { ...props.timeblock, starts_at_time: target.value });
};
const updateEndTime = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('updateTimeblock', { ...props.timeblock, ends_at_time: target.value });
};
</script>
<style lang="sass" scoped>
.timeblock-container
width: 100%
padding: 20px 0
border-top: 1px solid #e0e0e0
.timeblock-header
display: flex
align-items: center
gap: 15px
margin-bottom: 20px
flex-wrap: wrap
@media (max-width: 768px)
flex-direction: column
align-items: stretch
.date-navigation
display: flex
align-items: center
gap: 10px
flex: 1
.nav-btn
background: #f5f5f5
border: 1px solid #e0e0e0
border-radius: 4px
width: 36px
height: 36px
display: flex
align-items: center
justify-content: center
cursor: pointer
transition: all 0.2s
&:hover
background: #e0e0e0
&:active
background: #d0d0d0
.date-picker-input
flex: 1
.time-controls
display: flex
gap: 10px
flex: 1
@media (max-width: 768px)
width: 100%
::v-deep .v-input
min-width: 120px
.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-wrapper
margin-bottom: 15px
.timeline-loading
text-align: center
padding: 40px
color: #666
.timeline-container
position: relative
width: 100%
margin-bottom: 10px
.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: 5px
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>
@@ -0,0 +1,238 @@
<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>
+285 -258
View File
@@ -88,160 +88,84 @@
/>
</div>
<!-- TODO: Toto tu nema byt ci? -->
<!-- <div class="w-full">
<A-task-timeblocks
class="w-full"
:timeblocks="subtask.timeblocks"
:subtask="subtask"
@update-subtask="$emit('update-subtask')"
@update-unfinished-subtask="subtask = $event"
@delete-timeblock="deleteTimeblock($event)"
/>
</div> -->
<!-- Employee Assignment -->
<A-task-employees
v-model="subtask"
:is-edit-mode="!!subtask.id"
:validation-errors="employeeValidations"
:custom-filter="customFilter"
@remove-employee="handleRemoveEmployee"
@clear-validation="clearEmployeeValidation"
/>
<template v-if="subtask.id">
<div
class="employee-wrapper"
:class="{ 'hide-arrow': subtask.employee_id }"
>
<svg
<!-- Timeblocks per Employee -->
<div class="employees-timeblocks w-100">
<template v-if="subtask.id">
<!-- Edit Mode: Single Employee -->
<div
v-if="subtask.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="
handleOnRemoveEmployee();
subtask.employee_id = null;
subtask.employee = null;
"
class="employee-timeblocks-section"
>
<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
v-model="subtask.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: employeeValidations[0]?.employee }"
@update:model-value="
employeeValidations[0]
? (employeeValidations[0].employee = false)
: null
"
/>
</div>
<v-text-field
v-model="subtask.duration"
class="modal-input"
variant="solo"
type="number"
label="Časový odhad"
:class="{ error: employeeValidations[0]?.duration }"
append-inner-icon="mdi-clock-outline"
@update:model-value="
employeeValidations[0]
? (employeeValidations[0].duration = false)
: null
"
/>
</template>
<template v-else>
<div
v-for="(employee, index) in subtask.employees"
:key="`employee-${index}`"
class="splitted-inputs remove-employee"
:class="{ 'hide-arrow': employee?.id }"
>
<div class="relative left w-full">
<v-autocomplete
v-model="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: employeeValidations[index]?.employee }"
@update:model-value="
employeeValidations[index]
? (employeeValidations[index].employee = false)
: null
<div class="employee-header">
<h3>{{ getEmployeeFullName(subtask.employee_id) }}</h3>
</div>
<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)
"
/>
<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="
handleOnRemoveEmployee();
employee.id = null;
"
>
<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>
<div class="end-button w-100">
<button @click="addTimeblock(subtask.employee_id)">
Pridať časový blok
</button>
</div>
</div>
<v-text-field
v-model="employee.duration"
class="modal-input right"
variant="solo"
type="number"
label="Časový odhad"
append-inner-icon="mdi-clock-outline"
:class="{ error: employeeValidations[index]?.duration }"
@update:model-value="
employeeValidations[index]
? (employeeValidations[index].duration = false)
: null
"
/>
</div>
</template>
</template>
<div class="timeblocks w-100">
<A-timeblock
v-for="(timeblock, index) in editableTimeblocks"
:key="index"
:ref="`timetable-${index}`"
:timeblock="timeblock"
:deletable="index > 0"
:editable="true"
:index="index"
@update-timeblock="(val) => updateTimeblock(val, index)"
@remove-timeblock="removeTimeblock(index)"
/>
<div v-if="!subtask.id" class="end-button w-100">
<button @click="addTimeblock">Pridať časový blok</button>
</div>
<template v-else>
<!-- Create Mode: Multiple Employees -->
<div
v-for="employee in getValidEmployees()"
:key="`employee-${employee.id}`"
class="employee-timeblocks-section"
>
<div class="employee-header">
<h3>{{ getEmployeeFullName(employee.id) }}</h3>
</div>
<A-task-employee-timeline
v-for="(timeblock, index) in getEmployeeTimeblocks(
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>
</div>
<div class="w-100 modal-input quill-editor-wrapper">
@@ -278,10 +202,7 @@
<template v-else>
<template v-if="subtask.is_draft">
<v-btn
class="button primary"
:disabled="isSaving"
@click="save(true)"
<v-btn class="button primary" :disabled="isSaving" @click="save()"
>ULOŽIŤ A PUBLIKOVAŤ</v-btn
>
@@ -396,7 +317,8 @@ import '@vueup/vue-quill/dist/vue-quill.snow.css';
import AutocompleteMixin from '@/plugins/tm@_mixins/autocomplete.mixin';
import ZTaskReadonlyInfo from './_components/z-task-readonly-info.vue';
import ATimeblock from './_components/a-timeblock.vue';
import ATaskEmployees from './_components/a-task-employees.vue';
import ATaskEmployeeTimeline from './_components/a-task-employee-timeline.vue';
import { mapGetters } from 'vuex';
import modalMixin from '@/plugins/tm@modal/_mixins/modal.mixin';
import AResizeHandle from '@/plugins/tm@modal/_components/a-resize-handle.vue';
@@ -406,7 +328,8 @@ import ACloseButton from '@/plugins/tm@modal/_components/a-close-button.vue';
export default {
components: {
ZTaskReadonlyInfo,
ATimeblock,
ATaskEmployees,
ATaskEmployeeTimeline,
ZFilesUpload,
ZDatepicker,
// ATaskTimeblocks,
@@ -430,13 +353,7 @@ export default {
employeeValidations: [],
showDatePicker: false,
selectedNewDate: [],
timeblocks: [
{
date: null,
starts_at_time: null,
ends_at_time: null,
},
],
timeblocks: [],
wasFormValidationTriggered: false,
modalName: 'z-task-modal',
};
@@ -450,27 +367,6 @@ export default {
_fullName: `${mastertask.name} [${mastertask.project_code}]`,
}));
},
// Unified timeblocks for both create and update modes
editableTimeblocks: {
get() {
if (this.subtask?.id) {
// Update mode: use subtask.timeblocks (from BE)
return this.subtask.timeblocks || [];
}
// Create mode: use this.timeblocks (local state)
return this.timeblocks;
},
set(value) {
if (this.subtask?.id) {
// Update mode: update subtask.timeblocks (Vue 3 reactivity)
this.subtask.timeblocks = value;
} else {
// Create mode: update this.timeblocks
this.timeblocks = value;
}
},
},
},
validations() {
@@ -494,13 +390,34 @@ export default {
},
'subtask.employees': {
handler() {
handler(newEmployees, oldEmployees) {
if (!this.subtask?.employees) return;
const lastEmployee =
this.subtask.employees[this.subtask.employees.length - 1];
if (lastEmployee?.id || lastEmployee?.duration)
this.subtask.employees.push({});
// Auto-create first timeblock when employee gets both id and duration
if (newEmployees && oldEmployees) {
newEmployees.forEach((newEmp, index) => {
const oldEmp = oldEmployees[index] || {};
// Check if employee just became "complete" (has both id and duration)
const wasComplete = oldEmp.id && oldEmp.duration;
const isComplete = newEmp.id && newEmp.duration;
if (!wasComplete && isComplete) {
// Check if this employee already has timeblocks
const hasTimeblocks = this.timeblocks.some(
(tb) => tb.employee_id === newEmp.id,
);
if (!hasTimeblocks) {
// Add first timeblock for this employee
this.addTimeblock(newEmp.id);
}
}
});
}
},
deep: true,
},
@@ -567,7 +484,10 @@ export default {
data.timeblocks.forEach((timeblock) => {
// If already in internal format, keep as is
if (timeblock.date) {
convertedTimeblocks.push(timeblock);
convertedTimeblocks.push({
...timeblock,
employee_id: data.employee_id, // Add employee_id
});
return;
}
@@ -581,11 +501,13 @@ export default {
starts_at_time: startMoment.format('HH:mm'),
ends_at_time: endMoment.format('HH:mm'),
is_finished: timeblock.is_finished || 0,
employee_id: data.employee_id, // Add employee_id
});
}
});
}
data.timeblocks = convertedTimeblocks;
// Store in component's timeblocks array
this.timeblocks = convertedTimeblocks;
this.subtask = this._enrichTask(data);
@@ -612,42 +534,92 @@ export default {
this.$refs[ref].openMenu();
},
updateTimeblock(val, index) {
if (this.subtask?.id) {
// Update mode: direct assignment (Vue 3 reactivity)
this.subtask.timeblocks[index] = val;
} else {
// Create mode: direct assignment
this.timeblocks[index] = val;
// Helper: Get employee by ID
getEmployeeById(employeeId) {
return this.enrichedEmployees.find((emp) => emp.id === employeeId);
},
// Helper: Get employee full name
getEmployeeFullName(employeeId) {
const employee = this.getEmployeeById(employeeId);
return employee ? `${employee.name} ${employee.surname}` : '';
},
// Helper: Get valid employees (with id and duration) in create mode
getValidEmployees() {
if (!this.subtask?.employees) return [];
return this.subtask.employees.filter((emp) => emp.id && emp.duration);
},
// Helper: Get timeblocks for specific employee
getEmployeeTimeblocks(employeeId) {
if (!this.timeblocks) return [];
return this.timeblocks.filter((tb) => tb.employee_id === employeeId);
},
// 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;
}
},
addTimeblock() {
updateTimeblock(val, index, employeeId) {
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
const timeblock = employeeTimeblocks[index];
if (!timeblock) return;
// Find the actual index in the full timeblocks array
const actualIndex = this.timeblocks.indexOf(timeblock);
if (actualIndex !== -1) {
this.timeblocks[actualIndex] = { ...val, employee_id: employeeId };
}
},
addTimeblock(employeeId) {
const newTimeblock = {
date: null,
date: new Date(), // Preset to current day
starts_at_time: null,
ends_at_time: null,
employee_id: employeeId,
};
if (this.subtask?.id) {
// Update mode
if (!this.subtask.timeblocks) {
this.subtask.timeblocks = [];
}
this.subtask.timeblocks.push(newTimeblock);
} else {
// Create mode
this.timeblocks.push(newTimeblock);
this.timeblocks.push(newTimeblock);
},
removeTimeblock(index, employeeId) {
const employeeTimeblocks = this.getEmployeeTimeblocks(employeeId);
const timeblock = employeeTimeblocks[index];
if (!timeblock) return;
// Find the actual index in the full timeblocks array
const actualIndex = this.timeblocks.indexOf(timeblock);
if (actualIndex !== -1) {
this.timeblocks.splice(actualIndex, 1);
}
},
removeTimeblock(index) {
if (this.subtask?.id) {
// Update mode
this.subtask.timeblocks.splice(index, 1);
} else {
// Create mode
this.timeblocks.splice(index, 1);
// Handle remove employee - also removes their timeblocks
handleRemoveEmployee(employeeId) {
if (employeeId) {
// Remove all timeblocks for this employee
this.timeblocks = this.timeblocks.filter(
(tb) => tb.employee_id !== employeeId,
);
}
if (this.wasFormValidationTriggered) {
setTimeout(async () => {
this.validateAllFields();
}, 100);
}
},
@@ -659,13 +631,7 @@ export default {
this.preventInputFocus = true;
this.visible = true;
this.timeblocks = [
{
date: null,
starts_at_time: null,
ends_at_time: null,
},
];
this.timeblocks = [];
this.$nextTick(() => {
this.v$.$reset();
@@ -687,7 +653,10 @@ export default {
data.timeblocks.forEach((timeblock) => {
// If already in internal format, keep as is
if (timeblock.date) {
convertedTimeblocks.push(timeblock);
convertedTimeblocks.push({
...timeblock,
employee_id: data.employee_id, // Add employee_id for edit mode
});
return;
}
@@ -704,11 +673,18 @@ export default {
starts_at_time: startMoment.format('HH:mm'),
ends_at_time: endMoment.format('HH:mm'),
is_finished: timeblock.is_finished || 0,
employee_id: data.employee_id, // Add employee_id for edit mode
});
}
});
}
data.timeblocks = convertedTimeblocks;
// Store timeblocks in the component's timeblocks array
this.timeblocks = convertedTimeblocks;
// Ensure at least one timeblock exists for edit mode
if (data.employee_id && this.timeblocks.length === 0) {
this.addTimeblock(data.employee_id);
}
if (!data.deadline) {
this.editMode = editMode || !data.id;
@@ -796,14 +772,6 @@ export default {
}
},
async handleOnRemoveEmployee() {
if (this.wasFormValidationTriggered) {
setTimeout(async () => {
this.validateAllFields();
}, 100);
}
},
async save(concept = false) {
this.wasFormValidationTriggered = true;
if (!(await this.validateAllFields(concept))) return;
@@ -826,8 +794,12 @@ export default {
});
// Convert timeblocks from internal format to BE format for update
const timeblocks = (this.subtask.timeblocks || [])
.filter((timeblock) => timeblock.date) // Only send timeblocks with dates
const timeblocks = this.timeblocks
.filter(
(timeblock) =>
timeblock.date &&
timeblock.employee_id === this.subtask.employee_id,
) // Only send timeblocks for this employee
.map((timeblock) => {
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
return {
@@ -839,10 +811,17 @@ export default {
});
const payload = { ...this.subtask, timesheets, timeblocks };
console.log('isConcept', concept);
if (concept) {
payload.concept = true;
payload.is_draft = 1;
} else {
payload.is_draft = 0;
}
console.log('payload', payload);
const { subtask } = await this.$axios.patch_auth_form(
`v1/subtasks/${this.subtask.id}`,
payload,
@@ -857,9 +836,13 @@ export default {
(employee) => employee.id && employee.duration,
);
// Assign timeblocks to each employee based on employee_id
validEmployees.forEach((employee) => {
employee.timeblocks = this.timeblocks
.filter((timeblock) => timeblock.date)
.filter(
(timeblock) =>
timeblock.date && timeblock.employee_id === employee.id,
)
.map((timeblock) => {
return {
start: `${moment(timeblock.date).format('YYYY-MM-DD')} ${
@@ -995,18 +978,37 @@ export default {
},
async _validateTimeblocks() {
// Validate that all timeblocks have required fields and valid time ranges
let valid = true;
const timeblocks = this.editableTimeblocks;
for (let i = 0; i < timeblocks.length; i++) {
const ref = this.$refs[`timetable-${i}`];
if (ref && ref[0]) {
const isValid = await ref[0].v$.$validate();
if (!isValid) {
valid = false;
}
for (const timeblock of this.timeblocks) {
if (
!timeblock.date ||
!timeblock.starts_at_time ||
!timeblock.ends_at_time
) {
valid = false;
break;
}
// Validate that end time is after start time
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
const start = moment(
`${dateStr} ${timeblock.starts_at_time}`,
'YYYY-MM-DD HH:mm',
);
const end = moment(
`${dateStr} ${timeblock.ends_at_time}`,
'YYYY-MM-DD HH:mm',
);
if (end.isSameOrBefore(start)) {
this.$toast.error('Koniec času musí byť po začiatku');
valid = false;
break;
}
}
return valid;
},
@@ -1091,32 +1093,57 @@ export default {
.files
margin-top: 104px !important
.timeblocks
.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
border-bottom: 1px solid #e0e0e0
margin-top: 20px
padding-top: 20px
border-top: 1px solid #e0e0e0
.end-button
width: 100%
display: flex
justify-content: flex-end
align-items: center
border-top: 1px solid #e0e0e0
height: 70px
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
button
background: #7f39fb !important
border-radius: 4px !important
color: #fff !important
height: 50px
width: 203px
&:hover
background: #6b2dd9 !important
.splitted-time-inputs
display: flex !important
flex-direction: row !important
gap: 15px
margin-bottom: -11px
&:active
background: #5a24b8 !important
.timeblock
position: relative