mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 06:20:21 +00:00
628 lines
15 KiB
Vue
628 lines
15 KiB
Vue
<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>
|
||
|