mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 22:39:34 +00:00
wip(task-timeline): stage changes
This commit is contained in:
1 parent
b1771ead05
commit
69ea8be701
7 files changed
+561
-548
No files matched your search
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="employee-select">
|
||||
<v-autocomplete
|
||||
:model-value="modelValue.id"
|
||||
:custom-filter="customFilter"
|
||||
:match-any="true"
|
||||
label="Priradený"
|
||||
:items="enrichedEmployees"
|
||||
item-title="_fullName"
|
||||
item-value="id"
|
||||
variant="solo"
|
||||
class="employee-select__select-field"
|
||||
:class="{ error: v$.$errors.length && v$.id.$errors.length }"
|
||||
@update:model-value="handleUpdateEmployee"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
:model-value="modelValue.duration"
|
||||
class="employee-select__duration-field"
|
||||
variant="solo"
|
||||
type="number"
|
||||
label="Časový odhad"
|
||||
:class="{ error: v$.$errors.length && v$.duration.$errors.length }"
|
||||
append-inner-icon="mdi-clock-outline"
|
||||
@update:model-value="handleUpdateDuration"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="modelValue.id"
|
||||
class="employee-select__delete-button"
|
||||
@click="handleClearEmployee"
|
||||
>
|
||||
<v-icon icon="mdi-close" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import type { EmployeeData, CustomFilterFunction } from './types';
|
||||
|
||||
interface Props {
|
||||
modelValue: EmployeeData;
|
||||
customFilter?: CustomFilterFunction;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
customFilter: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: EmployeeData): void;
|
||||
(e: 'clearEmployee'): void;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const enrichedEmployees = computed(() => store.getters['tm/enrichedEmployees']);
|
||||
|
||||
// Vuelidate rules
|
||||
const rules = {
|
||||
id: { required },
|
||||
duration: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, props.modelValue);
|
||||
|
||||
const handleUpdateEmployee = (value: number) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
id: value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleUpdateDuration = (value: string) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
duration: parseFloat(value) || null,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleClearEmployee = () => {
|
||||
emit('clearEmployee');
|
||||
};
|
||||
|
||||
// Expose validate method
|
||||
const validate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
return isValid;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.employee-select
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: start
|
||||
gap: 10px
|
||||
|
||||
&__select-field
|
||||
width: 100%
|
||||
|
||||
&.error
|
||||
::v-deep .v-field
|
||||
border-bottom: 2px solid #ff5252 !important
|
||||
|
||||
&__duration-field
|
||||
width: 230px
|
||||
|
||||
&__delete-button
|
||||
background: #ff5252
|
||||
color: white
|
||||
border: none
|
||||
border-radius: 4px
|
||||
width: 56px
|
||||
height: 56px
|
||||
font-size: 16px
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
transition: all 0.2s
|
||||
line-height: 1
|
||||
flex-shrink: 0
|
||||
|
||||
&:hover
|
||||
background: #ff1744
|
||||
</style>
|
||||
@@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<div class="timeblock">
|
||||
<!-- Date and Time Controls -->
|
||||
<div class="timeblock__header">
|
||||
<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"
|
||||
class="timeblock__date-picker"
|
||||
@update:model-value="updateDate"
|
||||
/>
|
||||
|
||||
<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 -->
|
||||
<div v-if="modelValue.date && employee" 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">
|
||||
<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 { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
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';
|
||||
import type { TimeblockData, EmployeeFullData } from './types';
|
||||
|
||||
interface Props {
|
||||
modelValue: TimeblockData;
|
||||
employee: EmployeeFullData | null;
|
||||
deletable?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
deletable: false,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TimeblockData): 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;
|
||||
});
|
||||
|
||||
// Vuelidate rules
|
||||
const rules = {
|
||||
date: {},
|
||||
starts_at_time: {
|
||||
required: requiredIf(() => !!props.modelValue.date),
|
||||
},
|
||||
ends_at_time: {
|
||||
required: requiredIf(() => !!props.modelValue.date),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, props.modelValue);
|
||||
|
||||
// Reactive refs for composable
|
||||
const employeeRef = computed(() => props.employee);
|
||||
const dateRef = computed(() => props.modelValue.date);
|
||||
const timeblockRef = computed(() => props.modelValue);
|
||||
|
||||
// Use timeline composable
|
||||
const {
|
||||
isLoading,
|
||||
timelineTasks,
|
||||
scheduledTime,
|
||||
freeTime,
|
||||
collision,
|
||||
collisionSegments,
|
||||
timeblockDuration,
|
||||
refetch,
|
||||
} = useEmployeeTimeline(employeeRef, dateRef, timeblockRef);
|
||||
|
||||
// Force refetch when date changes
|
||||
watch(
|
||||
() => props.modelValue.date,
|
||||
() => {
|
||||
if (props.modelValue.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.modelValue);
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
// Validate that end time is after start time
|
||||
const isTimeRangeValid = computed(() => {
|
||||
if (!props.modelValue.starts_at_time || !props.modelValue.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.modelValue.date,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
centerTimeline();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Date navigation
|
||||
const previousDay = () => {
|
||||
if (!props.modelValue.date) return;
|
||||
|
||||
const currentDate = moment(props.modelValue.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.modelValue.date) return;
|
||||
|
||||
const currentDate = moment(props.modelValue.date);
|
||||
const newDate = currentDate.add(1, 'day').toDate();
|
||||
|
||||
updateDate(newDate);
|
||||
};
|
||||
|
||||
// Update handlers
|
||||
const updateDate = (date: Date | null) => {
|
||||
emit('update:modelValue', { ...props.modelValue, date });
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const updateStartTime = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
starts_at_time: target.value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const updateEndTime = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
ends_at_time: target.value,
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
// Expose validate method
|
||||
const validate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
return isValid && isTimeRangeValid.value;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.timeblock
|
||||
width: 100%
|
||||
border: 1px solid #e5e5e5
|
||||
border-radius: 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>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="employee" :class="{ 'employee--has-background': hasBackground }">
|
||||
<AEmployeeSelect
|
||||
ref="employeeSelectRef"
|
||||
v-model="employeeDataLocal"
|
||||
:custom-filter="customFilter"
|
||||
@clear-employee="handleClearEmployee"
|
||||
/>
|
||||
|
||||
<!-- Timeblocks Timeline -->
|
||||
<div v-if="selectedEmployee" class="employee__timeblocks">
|
||||
<AEmployeeTimeblock
|
||||
v-for="(timeblock, index) in timeblocks"
|
||||
:key="`timeblock-${index}`"
|
||||
:ref="(el) => setTimeblockRef(el, index)"
|
||||
:model-value="timeblock"
|
||||
:employee="selectedEmployee"
|
||||
:deletable="timeblocks.length > 1"
|
||||
:index="index"
|
||||
@update:model-value="(val) => handleUpdateTimeblock(index, val)"
|
||||
@remove-timeblock="handleRemoveTimeblock(index)"
|
||||
/>
|
||||
|
||||
<!-- Add Timeblock Button -->
|
||||
<div class="add-timeblock-btn-wrapper">
|
||||
<button class="add-timeblock-btn" @click="handleAddTimeblock">
|
||||
Pridať časový blok
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import AEmployeeSelect from './a-employee-select.vue';
|
||||
import AEmployeeTimeblock from './a-employee-timeblock.vue';
|
||||
import type {
|
||||
EmployeeData,
|
||||
TimeblockData,
|
||||
CustomFilterFunction,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
employeeData: EmployeeData;
|
||||
timeblocks?: TimeblockData[];
|
||||
customFilter?: CustomFilterFunction;
|
||||
hasBackground?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
timeblocks: () => [],
|
||||
customFilter: undefined,
|
||||
hasBackground: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:employeeData', value: EmployeeData): void;
|
||||
(e: 'update:timeblocks', value: TimeblockData[]): void;
|
||||
(e: 'clearEmployee'): void;
|
||||
(e: 'addTimeblock'): void;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// Local models with v-model sync
|
||||
const employeeDataLocal = computed({
|
||||
get: () => props.employeeData,
|
||||
set: (value: EmployeeData) => emit('update:employeeData', value),
|
||||
});
|
||||
|
||||
// Refs for validation
|
||||
const employeeSelectRef = ref<InstanceType<typeof AEmployeeSelect> | null>(
|
||||
null,
|
||||
);
|
||||
const timeblockRefs = ref<any[]>([]);
|
||||
|
||||
const setTimeblockRef = (el: any, index: number) => {
|
||||
if (el) {
|
||||
timeblockRefs.value[index] = el;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the full employee object from store
|
||||
const selectedEmployee = computed(() => {
|
||||
if (!props.employeeData.id) return null;
|
||||
const employees = store.getters['tm/enrichedEmployees'];
|
||||
return employees.find((emp: any) => emp.id === props.employeeData.id) || null;
|
||||
});
|
||||
|
||||
const handleClearEmployee = () => {
|
||||
emit('clearEmployee');
|
||||
};
|
||||
|
||||
const handleUpdateTimeblock = (index: number, value: TimeblockData) => {
|
||||
const updatedTimeblocks = [...props.timeblocks];
|
||||
updatedTimeblocks[index] = value;
|
||||
emit('update:timeblocks', updatedTimeblocks);
|
||||
};
|
||||
|
||||
const handleRemoveTimeblock = (index: number) => {
|
||||
const updatedTimeblocks = [...props.timeblocks];
|
||||
updatedTimeblocks.splice(index, 1);
|
||||
emit('update:timeblocks', updatedTimeblocks);
|
||||
};
|
||||
|
||||
const handleAddTimeblock = () => {
|
||||
emit('addTimeblock');
|
||||
};
|
||||
|
||||
// Expose validate method that validates all child components
|
||||
const validate = async () => {
|
||||
let isValid = true;
|
||||
|
||||
// Validate employee select
|
||||
if (employeeSelectRef.value) {
|
||||
const selectValid = await employeeSelectRef.value.validate();
|
||||
isValid = isValid && selectValid;
|
||||
}
|
||||
|
||||
// Validate all timeblocks
|
||||
for (const timeblockRef of timeblockRefs.value) {
|
||||
if (timeblockRef) {
|
||||
const timeblockValid = await timeblockRef.validate();
|
||||
isValid = isValid && timeblockValid;
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.employee
|
||||
width: 100%
|
||||
margin-bottom: 16px
|
||||
|
||||
&--has-background
|
||||
border: 1px solid #e5e5e5
|
||||
padding: 16px
|
||||
border-radius: 8px
|
||||
|
||||
.add-timeblock-btn-wrapper
|
||||
width: 100%
|
||||
display: flex
|
||||
justify-content: flex-end
|
||||
margin-top: 15px
|
||||
|
||||
.add-timeblock-btn
|
||||
padding: 10px 20px
|
||||
background-color: #7f39fb
|
||||
color: white
|
||||
border: none
|
||||
border-radius: 4px
|
||||
cursor: pointer
|
||||
font-size: 14px
|
||||
transition: background-color 0.2s
|
||||
|
||||
&:hover
|
||||
background-color: #6825e0
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Universal interfaces for employee components
|
||||
|
||||
export interface EmployeeData {
|
||||
id: number | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface TimeblockData {
|
||||
id?: number | null;
|
||||
date: Date | string | null;
|
||||
starts_at_time: string | null;
|
||||
ends_at_time: string | null;
|
||||
employee_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EmployeeFullData {
|
||||
id: number;
|
||||
name: string;
|
||||
surname: string;
|
||||
work_start: number;
|
||||
work_end: number;
|
||||
_fullName?: string;
|
||||
}
|
||||
|
||||
export type CustomFilterFunction = (
|
||||
value: string,
|
||||
query: string,
|
||||
item?: any,
|
||||
) => boolean | number | [number, number] | [number, number][];
|
||||
Reference in new issue
Block a user