Files
jandl-taskmanager-app/app/src/plugins/tm@modal/task/_components/a-task-employee-timeline.vue
T

628 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>