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
4768986a25
commit
b1771ead05
11 files changed
+2200
-662
No files matched your search
@@ -15,6 +15,12 @@ module.exports = {
|
|||||||
ecmaVersion: 2021,
|
ecmaVersion: 2021,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
defineProps: 'readonly',
|
||||||
|
defineEmits: 'readonly',
|
||||||
|
defineExpose: 'readonly',
|
||||||
|
withDefaults: 'readonly',
|
||||||
|
},
|
||||||
ignorePatterns: ['**/app@vendor/*', 'dist/**/*', 'node_modules/**/*'],
|
ignorePatterns: ['**/app@vendor/*', 'dist/**/*', 'node_modules/**/*'],
|
||||||
rules: {
|
rules: {
|
||||||
// Vue-specific rules
|
// Vue-specific rules
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ["@vue/cli-plugin-babel/preset"],
|
presets: ["@vue/cli-plugin-babel/preset"],
|
||||||
|
plugins: [
|
||||||
|
["@babel/plugin-proposal-private-methods", { loose: true }],
|
||||||
|
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||||
|
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
|
||||||
|
],
|
||||||
};
|
};
|
||||||
Generated
+619
-424
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,7 @@
|
|||||||
"@fullcalendar/vue3": "^6.0.2",
|
"@fullcalendar/vue3": "^6.0.2",
|
||||||
"@ionic/vue": "^6.3.6",
|
"@ionic/vue": "^6.3.6",
|
||||||
"@mdi/font": "5.9.55",
|
"@mdi/font": "5.9.55",
|
||||||
|
"@tanstack/vue-query": "^5.92.1",
|
||||||
"@vuelidate/core": "^2.0.0",
|
"@vuelidate/core": "^2.0.0",
|
||||||
"@vuelidate/validators": "^2.0.0",
|
"@vuelidate/validators": "^2.0.0",
|
||||||
"@vuepic/vue-datepicker": "^3.6.0",
|
"@vuepic/vue-datepicker": "^3.6.0",
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
"webfontloader": "^1.0.0"
|
"webfontloader": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||||
|
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||||
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@types/webfontloader": "^1.0.0",
|
"@types/webfontloader": "^1.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||||
"@typescript-eslint/parser": "^5.4.0",
|
"@typescript-eslint/parser": "^5.4.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import axiosCustom from '@/plugins/w@custom/axios'
|
|||||||
import tmStore from '@/plugins/tm@_store'
|
import tmStore from '@/plugins/tm@_store'
|
||||||
import vueCal from '@/plugins/app@vendor/vue-cal'
|
import vueCal from '@/plugins/app@vendor/vue-cal'
|
||||||
import { Options } from '@/plugins/w/w/types'
|
import { Options } from '@/plugins/w/w/types'
|
||||||
|
import { VueQueryPlugin, queryClient } from '@/plugins/w/query-client'
|
||||||
|
|
||||||
import '@ionic/vue/css/core.css'
|
import '@ionic/vue/css/core.css'
|
||||||
import '@ionic/vue/css/normalize.css'
|
import '@ionic/vue/css/normalize.css'
|
||||||
@@ -70,6 +71,7 @@ const app = createApp(App)
|
|||||||
.use(vuetify)
|
.use(vuetify)
|
||||||
.use(vueCal)
|
.use(vueCal)
|
||||||
.use(tmStore)
|
.use(tmStore)
|
||||||
|
.use(VueQueryPlugin, { queryClient })
|
||||||
.use(loader, {
|
.use(loader, {
|
||||||
color: '#6200EE'
|
color: '#6200EE'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
export interface Timeblock {
|
||||||
|
id?: number | null
|
||||||
|
date: Date | string | null
|
||||||
|
starts_at_time: string | null
|
||||||
|
ends_at_time: string | null
|
||||||
|
employee_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollisionInfo {
|
||||||
|
hasCollision: boolean
|
||||||
|
startCollides: boolean
|
||||||
|
endCollides: boolean
|
||||||
|
collidingTasks: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if two time ranges overlap
|
||||||
|
*/
|
||||||
|
export function doTimeRangesOverlap(
|
||||||
|
start1: Date,
|
||||||
|
end1: Date,
|
||||||
|
start2: Date,
|
||||||
|
end2: Date
|
||||||
|
): boolean {
|
||||||
|
return start1 < end2 && start2 < end1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a timeblock object to Date objects for start and end times
|
||||||
|
*/
|
||||||
|
export function timeblockToDateRange(timeblock: Timeblock): {
|
||||||
|
start: Date | null
|
||||||
|
end: Date | null
|
||||||
|
} {
|
||||||
|
if (!timeblock.date || !timeblock.starts_at_time || !timeblock.ends_at_time) {
|
||||||
|
return { start: null, end: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = moment(timeblock.date).format('YYYY-MM-DD')
|
||||||
|
const start = moment(`${dateStr} ${timeblock.starts_at_time}`, 'YYYY-MM-DD HH:mm').toDate()
|
||||||
|
const end = moment(`${dateStr} ${timeblock.ends_at_time}`, 'YYYY-MM-DD HH:mm').toDate()
|
||||||
|
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if a timeblock collides with any existing tasks
|
||||||
|
*/
|
||||||
|
export function detectCollision(
|
||||||
|
newTimeblock: Timeblock,
|
||||||
|
existingTasks: any[]
|
||||||
|
): CollisionInfo {
|
||||||
|
const result: CollisionInfo = {
|
||||||
|
hasCollision: false,
|
||||||
|
startCollides: false,
|
||||||
|
endCollides: false,
|
||||||
|
collidingTasks: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start: newStart, end: newEnd } = timeblockToDateRange(newTimeblock)
|
||||||
|
|
||||||
|
if (!newStart || !newEnd) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tasks to only check timeblocks on the same date
|
||||||
|
const tasksOnSameDate = existingTasks.filter((task) => {
|
||||||
|
if (!task._real_start) return false
|
||||||
|
const taskDate = moment(task._real_start).format('YYYY-MM-DD')
|
||||||
|
const newDate = moment(newTimeblock.date).format('YYYY-MM-DD')
|
||||||
|
return taskDate === newDate
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const task of tasksOnSameDate) {
|
||||||
|
// Skip if this is the same timeblock (comparing by ID)
|
||||||
|
if (newTimeblock.id && task._timeblock_id && newTimeblock.id === task._timeblock_id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskStart = new Date(task._real_start)
|
||||||
|
const taskEnd = new Date(task._real_end)
|
||||||
|
|
||||||
|
if (doTimeRangesOverlap(newStart, newEnd, taskStart, taskEnd)) {
|
||||||
|
result.hasCollision = true
|
||||||
|
result.collidingTasks.push(task)
|
||||||
|
|
||||||
|
// Check if start time is within an existing task
|
||||||
|
if (newStart >= taskStart && newStart < taskEnd) {
|
||||||
|
result.startCollides = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if end time is within an existing task
|
||||||
|
if (newEnd > taskStart && newEnd <= taskEnd) {
|
||||||
|
result.endCollides = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the new timeblock completely contains an existing task
|
||||||
|
if (newStart <= taskStart && newEnd >= taskEnd) {
|
||||||
|
result.startCollides = true
|
||||||
|
result.endCollides = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the start time of a timeblock collides with existing tasks
|
||||||
|
*/
|
||||||
|
export function isStartColliding(
|
||||||
|
newTimeblock: Timeblock,
|
||||||
|
existingTasks: any[]
|
||||||
|
): boolean {
|
||||||
|
const collision = detectCollision(newTimeblock, existingTasks)
|
||||||
|
return collision.startCollides
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the end time of a timeblock collides with existing tasks
|
||||||
|
*/
|
||||||
|
export function isEndColliding(
|
||||||
|
newTimeblock: Timeblock,
|
||||||
|
existingTasks: any[]
|
||||||
|
): boolean {
|
||||||
|
const collision = detectCollision(newTimeblock, existingTasks)
|
||||||
|
return collision.endCollides
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the total duration of a timeblock in hours
|
||||||
|
*/
|
||||||
|
export function calculateTimeblockDuration(timeblock: Timeblock): number {
|
||||||
|
const { start, end } = timeblockToDateRange(timeblock)
|
||||||
|
if (!start || !end) return 0
|
||||||
|
|
||||||
|
const diff = end.getTime() - start.getTime()
|
||||||
|
return diff / (1000 * 60 * 60) // Convert to hours
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collision segments for timeline visualization
|
||||||
|
* Returns an array of time ranges where collisions occur
|
||||||
|
*/
|
||||||
|
export function getCollisionSegments(
|
||||||
|
newTimeblock: Timeblock,
|
||||||
|
existingTasks: any[]
|
||||||
|
): Array<{ start: Date; end: Date }> {
|
||||||
|
const segments: Array<{ start: Date; end: Date }> = []
|
||||||
|
const { start: newStart, end: newEnd } = timeblockToDateRange(newTimeblock)
|
||||||
|
|
||||||
|
if (!newStart || !newEnd) {
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksOnSameDate = existingTasks.filter((task) => {
|
||||||
|
if (!task._real_start) return false
|
||||||
|
const taskDate = moment(task._real_start).format('YYYY-MM-DD')
|
||||||
|
const newDate = moment(newTimeblock.date).format('YYYY-MM-DD')
|
||||||
|
return taskDate === newDate
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const task of tasksOnSameDate) {
|
||||||
|
// Skip if this is the same timeblock (comparing by ID)
|
||||||
|
if (newTimeblock.id && task._timeblock_id && newTimeblock.id === task._timeblock_id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskStart = new Date(task._real_start)
|
||||||
|
const taskEnd = new Date(task._real_end)
|
||||||
|
|
||||||
|
if (doTimeRangesOverlap(newStart, newEnd, taskStart, taskEnd)) {
|
||||||
|
const overlapStart = new Date(Math.max(newStart.getTime(), taskStart.getTime()))
|
||||||
|
const overlapEnd = new Date(Math.min(newEnd.getTime(), taskEnd.getTime()))
|
||||||
|
segments.push({ start: overlapStart, end: overlapEnd })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { computed, Ref, unref } from 'vue'
|
||||||
|
import axios from '@/plugins/w@custom/axios/models/axios'
|
||||||
|
import moment from 'moment'
|
||||||
|
import timelineUtils from '@/plugins/tm@dashboard/tasks/_utils/timeline.utils'
|
||||||
|
import {
|
||||||
|
detectCollision,
|
||||||
|
calculateTimeblockDuration,
|
||||||
|
getCollisionSegments,
|
||||||
|
type Timeblock,
|
||||||
|
type CollisionInfo,
|
||||||
|
} from './timeblock.utils'
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
surname: string
|
||||||
|
work_start: number
|
||||||
|
work_end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmployeeTimeline(
|
||||||
|
employee: Ref<Employee | null>,
|
||||||
|
date: Ref<Date | string | null>,
|
||||||
|
currentTimeblock: Ref<Timeblock | null>
|
||||||
|
) {
|
||||||
|
// Fetch employee's tasks for the selected date
|
||||||
|
const { data: tasksData, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: computed(() => [
|
||||||
|
'employee-timeline',
|
||||||
|
unref(employee)?.id,
|
||||||
|
unref(date) ? moment(unref(date)).format('YYYY-MM-DD') : null,
|
||||||
|
]),
|
||||||
|
queryFn: async () => {
|
||||||
|
const emp = unref(employee)
|
||||||
|
const d = unref(date)
|
||||||
|
|
||||||
|
if (!emp?.id || !d) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = moment(d).format('YYYY-MM-DD')
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
start: dateStr,
|
||||||
|
end: dateStr,
|
||||||
|
employee_id: emp.id.toString(),
|
||||||
|
}).toString()
|
||||||
|
|
||||||
|
const response = await axios.get_auth(`v1/subtasks?${queryParams}`)
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
enabled: computed(() => {
|
||||||
|
const emp = unref(employee)
|
||||||
|
const d = unref(date)
|
||||||
|
return !!(emp?.id && d)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate timeline tasks from fetched data
|
||||||
|
const timelineTasks = computed(() => {
|
||||||
|
const data = unref(tasksData)
|
||||||
|
const emp = unref(employee)
|
||||||
|
const d = unref(date)
|
||||||
|
|
||||||
|
if (!data || !emp || !d) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtasks = [...(data.subtasks || []), ...(data.blockers || [])]
|
||||||
|
const dateObj = new Date(d)
|
||||||
|
const selectedDateStr = moment(d).format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process timeblocks directly instead of using timeline utils
|
||||||
|
// which is designed for multi-day views
|
||||||
|
const tasks: any[] = []
|
||||||
|
|
||||||
|
subtasks.forEach((subtask) => {
|
||||||
|
if (!['blocker', 'adhoc', 'home_office', 'vacation', 'free_day', 'office365_blocker'].includes(subtask.type)) {
|
||||||
|
subtask.timeblocks?.forEach((timeblock: any) => {
|
||||||
|
const timeblockDateStr = moment(timeblock.start).format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
// Only include timeblocks for the selected date
|
||||||
|
if (timeblockDateStr === selectedDateStr) {
|
||||||
|
let type = 'subtask'
|
||||||
|
if (subtask.type === 'office365') type = 'office365'
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
id: subtask.id,
|
||||||
|
start: new Date(timeblock.start),
|
||||||
|
end: new Date(timeblock.end),
|
||||||
|
master_task: subtask.master_task,
|
||||||
|
date: new Date(timeblock.start),
|
||||||
|
project_code: subtask.project_code,
|
||||||
|
subtask: subtask,
|
||||||
|
type,
|
||||||
|
_timeblock_id: timeblock.id,
|
||||||
|
_timeblock_is_finished: timeblock.is_finished,
|
||||||
|
_real_start: new Date(timeblock.start),
|
||||||
|
_real_end: new Date(timeblock.end),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating timeline tasks:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate scheduled time for the day (in hours)
|
||||||
|
const scheduledTime = computed(() => {
|
||||||
|
const tasks = unref(timelineTasks)
|
||||||
|
const d = unref(date)
|
||||||
|
const now = moment()
|
||||||
|
|
||||||
|
if (!tasks || !d) return 0
|
||||||
|
|
||||||
|
let totalMinutes = 0
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (
|
||||||
|
task.type === 'non-working-hours' ||
|
||||||
|
task.subtask?.is_finished ||
|
||||||
|
task.blocker?.is_finished
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now.isAfter(moment(task._real_end))) {
|
||||||
|
// Ignore elapsed tasks
|
||||||
|
return
|
||||||
|
} else if (
|
||||||
|
now.isAfter(moment(task._real_start)) &&
|
||||||
|
now.isBefore(moment(task._real_end))
|
||||||
|
) {
|
||||||
|
// Task in progress
|
||||||
|
totalMinutes += timelineUtils._getDifferenceInMinutes(task._real_end, new Date())
|
||||||
|
} else {
|
||||||
|
// Future task
|
||||||
|
totalMinutes += timelineUtils._getDifferenceInMinutes(
|
||||||
|
task._real_end,
|
||||||
|
task._real_start
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (totalMinutes / 60).toFixed(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate free/unscheduled time for the day (in hours)
|
||||||
|
const freeTime = computed(() => {
|
||||||
|
const emp = unref(employee)
|
||||||
|
const d = unref(date)
|
||||||
|
const scheduled = parseFloat(String(unref(scheduledTime)))
|
||||||
|
|
||||||
|
if (!emp || !d) return '0'
|
||||||
|
|
||||||
|
const now = moment()
|
||||||
|
const dateObj = moment(d)
|
||||||
|
const dailyCapacityHours = emp.work_end - emp.work_start
|
||||||
|
|
||||||
|
if (dateObj.isBefore(now, 'days')) {
|
||||||
|
// Past date
|
||||||
|
return '0'
|
||||||
|
} else if (dateObj.isSame(now, 'days')) {
|
||||||
|
// Today - calculate remaining capacity
|
||||||
|
const elapsedMinutes = now.diff(now.clone().startOf('day'), 'minutes')
|
||||||
|
const workStart =
|
||||||
|
emp.work_start * 60 > elapsedMinutes ? emp.work_start : elapsedMinutes / 60
|
||||||
|
const todayCapacityHours = emp.work_end - workStart
|
||||||
|
const free = todayCapacityHours - scheduled
|
||||||
|
return free < 0 ? '0' : free.toFixed(1)
|
||||||
|
} else {
|
||||||
|
// Future date
|
||||||
|
const free = dailyCapacityHours - scheduled
|
||||||
|
return free < 0 ? '0' : free.toFixed(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Detect collisions with current timeblock
|
||||||
|
const collision = computed<CollisionInfo>(() => {
|
||||||
|
const timeblock = unref(currentTimeblock)
|
||||||
|
const tasks = unref(timelineTasks)
|
||||||
|
|
||||||
|
if (!timeblock || !tasks) {
|
||||||
|
return {
|
||||||
|
hasCollision: false,
|
||||||
|
startCollides: false,
|
||||||
|
endCollides: false,
|
||||||
|
collidingTasks: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectCollision(timeblock, tasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get collision segments for timeline visualization
|
||||||
|
const collisionSegments = computed(() => {
|
||||||
|
const timeblock = unref(currentTimeblock)
|
||||||
|
const tasks = unref(timelineTasks)
|
||||||
|
|
||||||
|
if (!timeblock || !tasks) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCollisionSegments(timeblock, tasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate current timeblock duration
|
||||||
|
const timeblockDuration = computed(() => {
|
||||||
|
const timeblock = unref(currentTimeblock)
|
||||||
|
if (!timeblock) return 0
|
||||||
|
return calculateTimeblockDuration(timeblock)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Query states
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
|
||||||
|
// Timeline data
|
||||||
|
timelineTasks,
|
||||||
|
scheduledTime,
|
||||||
|
freeTime,
|
||||||
|
|
||||||
|
// Collision detection
|
||||||
|
collision,
|
||||||
|
collisionSegments,
|
||||||
|
timeblockDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -88,160 +88,84 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TODO: Toto tu nema byt ci? -->
|
<!-- Employee Assignment -->
|
||||||
<!-- <div class="w-full">
|
<A-task-employees
|
||||||
<A-task-timeblocks
|
v-model="subtask"
|
||||||
class="w-full"
|
:is-edit-mode="!!subtask.id"
|
||||||
:timeblocks="subtask.timeblocks"
|
:validation-errors="employeeValidations"
|
||||||
:subtask="subtask"
|
|
||||||
@update-subtask="$emit('update-subtask')"
|
|
||||||
@update-unfinished-subtask="subtask = $event"
|
|
||||||
@delete-timeblock="deleteTimeblock($event)"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<template v-if="subtask.id">
|
|
||||||
<div
|
|
||||||
class="employee-wrapper"
|
|
||||||
:class="{ 'hide-arrow': subtask.employee_id }"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
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;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<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"
|
:custom-filter="customFilter"
|
||||||
:match-any="true"
|
@remove-employee="handleRemoveEmployee"
|
||||||
label="Priradený"
|
@clear-validation="clearEmployeeValidation"
|
||||||
: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
|
<!-- Timeblocks per Employee -->
|
||||||
v-model="subtask.duration"
|
<div class="employees-timeblocks w-100">
|
||||||
class="modal-input"
|
<template v-if="subtask.id">
|
||||||
variant="solo"
|
<!-- Edit Mode: Single Employee -->
|
||||||
type="number"
|
<div
|
||||||
label="Časový odhad"
|
v-if="subtask.employee_id"
|
||||||
:class="{ error: employeeValidations[0]?.duration }"
|
class="employee-timeblocks-section"
|
||||||
append-inner-icon="mdi-clock-outline"
|
>
|
||||||
@update:model-value="
|
<div class="employee-header">
|
||||||
employeeValidations[0]
|
<h3>{{ getEmployeeFullName(subtask.employee_id) }}</h3>
|
||||||
? (employeeValidations[0].duration = false)
|
</div>
|
||||||
: null
|
<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)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
<div class="end-button w-100">
|
||||||
|
<button @click="addTimeblock(subtask.employee_id)">
|
||||||
|
Pridať časový blok
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- Create Mode: Multiple Employees -->
|
||||||
<div
|
<div
|
||||||
v-for="(employee, index) in subtask.employees"
|
v-for="employee in getValidEmployees()"
|
||||||
:key="`employee-${index}`"
|
:key="`employee-${employee.id}`"
|
||||||
class="splitted-inputs remove-employee"
|
class="employee-timeblocks-section"
|
||||||
:class="{ 'hide-arrow': employee?.id }"
|
|
||||||
>
|
>
|
||||||
<div class="relative left w-full">
|
<div class="employee-header">
|
||||||
<v-autocomplete
|
<h3>{{ getEmployeeFullName(employee.id) }}</h3>
|
||||||
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
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
<v-text-field
|
<A-task-employee-timeline
|
||||||
v-model="employee.duration"
|
v-for="(timeblock, index) in getEmployeeTimeblocks(
|
||||||
class="modal-input right"
|
employee.id,
|
||||||
variant="solo"
|
)"
|
||||||
type="number"
|
:key="`timeblock-${employee.id}-${index}`"
|
||||||
label="Časový odhad"
|
:timeblock="timeblock"
|
||||||
append-inner-icon="mdi-clock-outline"
|
:employee="getEmployeeById(employee.id)"
|
||||||
:class="{ error: employeeValidations[index]?.duration }"
|
:deletable="getEmployeeTimeblocks(employee.id).length > 1"
|
||||||
@update:model-value="
|
:index="index"
|
||||||
employeeValidations[index]
|
@update-timeblock="
|
||||||
? (employeeValidations[index].duration = false)
|
(val) => updateTimeblock(val, index, employee.id)
|
||||||
: null
|
|
||||||
"
|
"
|
||||||
|
@remove-timeblock="removeTimeblock(index, employee.id)"
|
||||||
/>
|
/>
|
||||||
|
<div class="end-button w-100">
|
||||||
|
<button @click="addTimeblock(employee.id)">
|
||||||
|
Pridať časový blok
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-100 modal-input quill-editor-wrapper">
|
<div class="w-100 modal-input quill-editor-wrapper">
|
||||||
@@ -278,10 +202,7 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="subtask.is_draft">
|
<template v-if="subtask.is_draft">
|
||||||
<v-btn
|
<v-btn class="button primary" :disabled="isSaving" @click="save()"
|
||||||
class="button primary"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="save(true)"
|
|
||||||
>ULOŽIŤ A PUBLIKOVAŤ</v-btn
|
>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 AutocompleteMixin from '@/plugins/tm@_mixins/autocomplete.mixin';
|
||||||
|
|
||||||
import ZTaskReadonlyInfo from './_components/z-task-readonly-info.vue';
|
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 { mapGetters } from 'vuex';
|
||||||
import modalMixin from '@/plugins/tm@modal/_mixins/modal.mixin';
|
import modalMixin from '@/plugins/tm@modal/_mixins/modal.mixin';
|
||||||
import AResizeHandle from '@/plugins/tm@modal/_components/a-resize-handle.vue';
|
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 {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ZTaskReadonlyInfo,
|
ZTaskReadonlyInfo,
|
||||||
ATimeblock,
|
ATaskEmployees,
|
||||||
|
ATaskEmployeeTimeline,
|
||||||
ZFilesUpload,
|
ZFilesUpload,
|
||||||
ZDatepicker,
|
ZDatepicker,
|
||||||
// ATaskTimeblocks,
|
// ATaskTimeblocks,
|
||||||
@@ -430,13 +353,7 @@ export default {
|
|||||||
employeeValidations: [],
|
employeeValidations: [],
|
||||||
showDatePicker: false,
|
showDatePicker: false,
|
||||||
selectedNewDate: [],
|
selectedNewDate: [],
|
||||||
timeblocks: [
|
timeblocks: [],
|
||||||
{
|
|
||||||
date: null,
|
|
||||||
starts_at_time: null,
|
|
||||||
ends_at_time: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
wasFormValidationTriggered: false,
|
wasFormValidationTriggered: false,
|
||||||
modalName: 'z-task-modal',
|
modalName: 'z-task-modal',
|
||||||
};
|
};
|
||||||
@@ -450,27 +367,6 @@ export default {
|
|||||||
_fullName: `${mastertask.name} [${mastertask.project_code}]`,
|
_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() {
|
validations() {
|
||||||
@@ -494,13 +390,34 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'subtask.employees': {
|
'subtask.employees': {
|
||||||
handler() {
|
handler(newEmployees, oldEmployees) {
|
||||||
if (!this.subtask?.employees) return;
|
if (!this.subtask?.employees) return;
|
||||||
|
|
||||||
const lastEmployee =
|
const lastEmployee =
|
||||||
this.subtask.employees[this.subtask.employees.length - 1];
|
this.subtask.employees[this.subtask.employees.length - 1];
|
||||||
if (lastEmployee?.id || lastEmployee?.duration)
|
if (lastEmployee?.id || lastEmployee?.duration)
|
||||||
this.subtask.employees.push({});
|
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,
|
deep: true,
|
||||||
},
|
},
|
||||||
@@ -567,7 +484,10 @@ export default {
|
|||||||
data.timeblocks.forEach((timeblock) => {
|
data.timeblocks.forEach((timeblock) => {
|
||||||
// If already in internal format, keep as is
|
// If already in internal format, keep as is
|
||||||
if (timeblock.date) {
|
if (timeblock.date) {
|
||||||
convertedTimeblocks.push(timeblock);
|
convertedTimeblocks.push({
|
||||||
|
...timeblock,
|
||||||
|
employee_id: data.employee_id, // Add employee_id
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,11 +501,13 @@ export default {
|
|||||||
starts_at_time: startMoment.format('HH:mm'),
|
starts_at_time: startMoment.format('HH:mm'),
|
||||||
ends_at_time: endMoment.format('HH:mm'),
|
ends_at_time: endMoment.format('HH:mm'),
|
||||||
is_finished: timeblock.is_finished || 0,
|
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);
|
this.subtask = this._enrichTask(data);
|
||||||
|
|
||||||
@@ -612,42 +534,92 @@ export default {
|
|||||||
this.$refs[ref].openMenu();
|
this.$refs[ref].openMenu();
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTimeblock(val, index) {
|
// Helper: Get employee by ID
|
||||||
if (this.subtask?.id) {
|
getEmployeeById(employeeId) {
|
||||||
// Update mode: direct assignment (Vue 3 reactivity)
|
return this.enrichedEmployees.find((emp) => emp.id === employeeId);
|
||||||
this.subtask.timeblocks[index] = val;
|
},
|
||||||
} else {
|
|
||||||
// Create mode: direct assignment
|
// Helper: Get employee full name
|
||||||
this.timeblocks[index] = val;
|
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 = {
|
const newTimeblock = {
|
||||||
date: null,
|
date: new Date(), // Preset to current day
|
||||||
starts_at_time: null,
|
starts_at_time: null,
|
||||||
ends_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) {
|
// Handle remove employee - also removes their timeblocks
|
||||||
if (this.subtask?.id) {
|
handleRemoveEmployee(employeeId) {
|
||||||
// Update mode
|
if (employeeId) {
|
||||||
this.subtask.timeblocks.splice(index, 1);
|
// Remove all timeblocks for this employee
|
||||||
} else {
|
this.timeblocks = this.timeblocks.filter(
|
||||||
// Create mode
|
(tb) => tb.employee_id !== employeeId,
|
||||||
this.timeblocks.splice(index, 1);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wasFormValidationTriggered) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
this.validateAllFields();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -659,13 +631,7 @@ export default {
|
|||||||
this.preventInputFocus = true;
|
this.preventInputFocus = true;
|
||||||
|
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
this.timeblocks = [
|
this.timeblocks = [];
|
||||||
{
|
|
||||||
date: null,
|
|
||||||
starts_at_time: null,
|
|
||||||
ends_at_time: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.v$.$reset();
|
this.v$.$reset();
|
||||||
|
|
||||||
@@ -687,7 +653,10 @@ export default {
|
|||||||
data.timeblocks.forEach((timeblock) => {
|
data.timeblocks.forEach((timeblock) => {
|
||||||
// If already in internal format, keep as is
|
// If already in internal format, keep as is
|
||||||
if (timeblock.date) {
|
if (timeblock.date) {
|
||||||
convertedTimeblocks.push(timeblock);
|
convertedTimeblocks.push({
|
||||||
|
...timeblock,
|
||||||
|
employee_id: data.employee_id, // Add employee_id for edit mode
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,11 +673,18 @@ export default {
|
|||||||
starts_at_time: startMoment.format('HH:mm'),
|
starts_at_time: startMoment.format('HH:mm'),
|
||||||
ends_at_time: endMoment.format('HH:mm'),
|
ends_at_time: endMoment.format('HH:mm'),
|
||||||
is_finished: timeblock.is_finished || 0,
|
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) {
|
if (!data.deadline) {
|
||||||
this.editMode = editMode || !data.id;
|
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) {
|
async save(concept = false) {
|
||||||
this.wasFormValidationTriggered = true;
|
this.wasFormValidationTriggered = true;
|
||||||
if (!(await this.validateAllFields(concept))) return;
|
if (!(await this.validateAllFields(concept))) return;
|
||||||
@@ -826,8 +794,12 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert timeblocks from internal format to BE format for update
|
// Convert timeblocks from internal format to BE format for update
|
||||||
const timeblocks = (this.subtask.timeblocks || [])
|
const timeblocks = this.timeblocks
|
||||||
.filter((timeblock) => timeblock.date) // Only send timeblocks with dates
|
.filter(
|
||||||
|
(timeblock) =>
|
||||||
|
timeblock.date &&
|
||||||
|
timeblock.employee_id === this.subtask.employee_id,
|
||||||
|
) // Only send timeblocks for this employee
|
||||||
.map((timeblock) => {
|
.map((timeblock) => {
|
||||||
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
|
const dateStr = moment(timeblock.date).format('YYYY-MM-DD');
|
||||||
return {
|
return {
|
||||||
@@ -839,10 +811,17 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = { ...this.subtask, timesheets, timeblocks };
|
const payload = { ...this.subtask, timesheets, timeblocks };
|
||||||
|
|
||||||
|
console.log('isConcept', concept);
|
||||||
|
|
||||||
if (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(
|
const { subtask } = await this.$axios.patch_auth_form(
|
||||||
`v1/subtasks/${this.subtask.id}`,
|
`v1/subtasks/${this.subtask.id}`,
|
||||||
payload,
|
payload,
|
||||||
@@ -857,9 +836,13 @@ export default {
|
|||||||
(employee) => employee.id && employee.duration,
|
(employee) => employee.id && employee.duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Assign timeblocks to each employee based on employee_id
|
||||||
validEmployees.forEach((employee) => {
|
validEmployees.forEach((employee) => {
|
||||||
employee.timeblocks = this.timeblocks
|
employee.timeblocks = this.timeblocks
|
||||||
.filter((timeblock) => timeblock.date)
|
.filter(
|
||||||
|
(timeblock) =>
|
||||||
|
timeblock.date && timeblock.employee_id === employee.id,
|
||||||
|
)
|
||||||
.map((timeblock) => {
|
.map((timeblock) => {
|
||||||
return {
|
return {
|
||||||
start: `${moment(timeblock.date).format('YYYY-MM-DD')} ${
|
start: `${moment(timeblock.date).format('YYYY-MM-DD')} ${
|
||||||
@@ -995,18 +978,37 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async _validateTimeblocks() {
|
async _validateTimeblocks() {
|
||||||
|
// Validate that all timeblocks have required fields and valid time ranges
|
||||||
let valid = true;
|
let valid = true;
|
||||||
const timeblocks = this.editableTimeblocks;
|
|
||||||
|
|
||||||
for (let i = 0; i < timeblocks.length; i++) {
|
for (const timeblock of this.timeblocks) {
|
||||||
const ref = this.$refs[`timetable-${i}`];
|
if (
|
||||||
if (ref && ref[0]) {
|
!timeblock.date ||
|
||||||
const isValid = await ref[0].v$.$validate();
|
!timeblock.starts_at_time ||
|
||||||
if (!isValid) {
|
!timeblock.ends_at_time
|
||||||
|
) {
|
||||||
valid = false;
|
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;
|
return valid;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1091,19 +1093,39 @@ export default {
|
|||||||
.files
|
.files
|
||||||
margin-top: 104px !important
|
margin-top: 104px !important
|
||||||
|
|
||||||
.timeblocks
|
.employees-timeblocks
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
align-items: center
|
width: 100%
|
||||||
border-bottom: 1px solid #e0e0e0
|
margin-top: 20px
|
||||||
|
|
||||||
.end-button
|
.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%
|
width: 100%
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: flex-end
|
justify-content: flex-end
|
||||||
align-items: center
|
align-items: center
|
||||||
|
margin-top: 20px
|
||||||
|
padding-top: 20px
|
||||||
border-top: 1px solid #e0e0e0
|
border-top: 1px solid #e0e0e0
|
||||||
height: 70px
|
|
||||||
|
|
||||||
button
|
button
|
||||||
background: #7f39fb !important
|
background: #7f39fb !important
|
||||||
@@ -1111,12 +1133,17 @@ export default {
|
|||||||
color: #fff !important
|
color: #fff !important
|
||||||
height: 50px
|
height: 50px
|
||||||
width: 203px
|
width: 203px
|
||||||
|
border: none
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.2s
|
||||||
|
font-size: 14px
|
||||||
|
font-weight: 500
|
||||||
|
|
||||||
.splitted-time-inputs
|
&:hover
|
||||||
display: flex !important
|
background: #6b2dd9 !important
|
||||||
flex-direction: row !important
|
|
||||||
gap: 15px
|
&:active
|
||||||
margin-bottom: -11px
|
background: #5a24b8 !important
|
||||||
|
|
||||||
.timeblock
|
.timeblock
|
||||||
position: relative
|
position: relative
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { VueQueryPlugin };
|
||||||
Reference in new issue
Block a user