Merge branch 'task000/concepts' of bitbucket.org:studiotem/jandl-taskmanager-app

This commit is contained in:
Matej Glasnák committed 2025-12-03 13:16:19 +01:00
commit b889d153cf
126 files changed
+744 -21762

No files matched your search

+18 -12
View File
@@ -52,18 +52,24 @@ const routes: Array<RouteRecordRaw> = [
path: '/app/dashboard',
redirect: '/app/dashboard/tasks',
children: [
{
path: '/app/dashboard/tasks',
name: 'Dashboard - Tasks',
meta: { title: 'Sub Tasks', requiresAuth: true },
component: () => import('@/plugins/tm@dashboard/tasks/tasks.vue')
},
{
path: '/app/dashboard/teams',
name: 'Dashboard - Teams',
meta: { title: 'Teams Overview', requiresAuth: true },
component: () => import('@/plugins/tm@dashboard/teams/teams.vue')
},
{
path: '/app/dashboard/tasks',
name: 'Dashboard - Tasks',
meta: { title: 'Sub Tasks', requiresAuth: true },
component: () => import('@/plugins/tm@dashboard/tasks/tasks.vue')
},
{
path: '/app/dashboard/tasks-concepts',
name: 'Dashboard - Tasks Concepts',
meta: { title: 'Draft Tasks', requiresAuth: true },
component: () => import('@/plugins/tm@dashboard/tasks-concepts/tasks-concepts.vue')
},
{
path: '/app/dashboard/teams',
name: 'Dashboard - Teams',
meta: { title: 'Teams Overview', requiresAuth: true },
component: () => import('@/plugins/tm@dashboard/teams/teams.vue')
},
{
path: '/app/dashboard/projects',
name: 'Dashboard - Projects',
@@ -14,8 +14,8 @@
:key="page.pageName"
:class="{ active: isActive(page.pageName) }"
@click="$router.push({ name: page.pageName }); closeMenu()"> {{ page.title }}
<span
v-if="page.pageName == 'Overview - Unscheduled tasks' && unscheduledSubtasks?.length >= 1"
<span
v-if="page.pageName == 'Overview - Unscheduled tasks' && unscheduledSubtasks?.length >= 1"
class="mb-4 text-center rounded-circle badge">{{ unscheduledSubtasks?.length }}</span>
</ion-item>
</ion-list>
@@ -33,6 +33,10 @@ export default {
title: 'Sub Tasks',
pageName: 'Dashboard - Tasks'
},
{
title: 'Sub Tasks Concepts',
pageName: 'Dashboard - Tasks Concepts'
},
{
title: 'Teams overview',
pageName: 'Dashboard - Teams'
@@ -0,0 +1,331 @@
<template>
<div
class="z-datepicker-wrapper"
:class="{ 'has-value': hasValue, 'is-focused': isFocused }"
@click="handleClick"
>
<label v-if="label" class="z-datepicker-label">{{ label }}</label>
<Datepicker
ref="datepickerRef"
:model-value="modelValue"
v-bind="$attrs"
:placeholder="hasValue ? ' ' : placeholder"
@update:model-value="handleUpdate"
@open="handleOpen"
@closed="handleClose"
/>
</div>
</template>
<script>
import Datepicker from '@vuepic/vue-datepicker';
export default {
name: 'ZDatepicker',
components: {
Datepicker,
},
props: {
modelValue: {
type: [Date, String, null],
default: null,
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'open', 'closed'],
data() {
return {
isFocused: false,
preventInputFocus: false,
savedScrollPosition: null,
focusListeners: new Map(),
};
},
computed: {
hasValue() {
return this.modelValue != null && this.modelValue !== '';
},
},
mounted() {
// Attach focus listeners on mount to catch any focus events
// Use immediate attachment (no $nextTick) to catch early focus events
this.attachFocusListeners();
},
methods: {
handleUpdate(value) {
this.$emit('update:modelValue', value);
},
handleOpen(event) {
this.isFocused = true;
// IMPORTANT: If scroll position wasn't saved yet (handleClick wasn't called),
// save it now but it might be too late and already scrolled to 0
if (this.savedScrollPosition === null) {
this.preventInputFocus = true;
const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop;
}
}
// CRITICAL: Blur any currently focused input (v-dialog autofocus issue)
this.blurFocusedInputs();
// Reattach focus event listeners (for dynamically added inputs)
this.attachFocusListeners();
// Position the calendar menu relative to the modal
this.positionCalendar();
// Reset after datepicker is opened
setTimeout(() => {
this.preventInputFocus = false;
this.savedScrollPosition = null;
}, 500);
// Emit to parent (for backward compatibility)
const wrapperElement =
event?.target?.closest?.('.z-datepicker-wrapper') || true;
this.$emit('open', wrapperElement);
},
handleClose(event) {
this.isFocused = false;
this.$emit('closed', event);
},
handleClick(event) {
// Prevent any default behavior
event.preventDefault();
event.stopPropagation();
// CRITICAL: Set prevention flag BEFORE opening menu to catch early focus events
this.preventInputFocus = true;
// Save scroll position FIRST (before blur or opening menu)
const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop;
}
// CRITICAL: Blur any currently focused input
this.blurFocusedInputs(false); // Pass false to not restore scroll yet
// Open the menu
if (this.$refs.datepickerRef?.openMenu) {
this.$refs.datepickerRef.openMenu();
}
},
openMenu() {
if (this.$refs.datepickerRef?.openMenu) {
this.$refs.datepickerRef.openMenu();
}
},
positionCalendar() {
setTimeout(() => {
const calendar = document.querySelector(
'.dp__menu.dp__menu_index.dp__theme_light',
);
if (!calendar) return;
// Get the datepicker wrapper element
const datepickerWrapper = this.$el;
// Get the modal container
const modalContainer = document.querySelector('.v-overlay__content');
if (!datepickerWrapper || !modalContainer) return;
// Get bounding rectangles
const wrapperRect = datepickerWrapper.getBoundingClientRect();
const modalRect = modalContainer.getBoundingClientRect();
// Calculate position relative to modal
const topPosition =
wrapperRect.top - modalRect.top + wrapperRect.height;
const leftPosition = wrapperRect.left - modalRect.left;
// Apply positioning
calendar.style.top = `${topPosition + 210}px`;
}, 100);
},
blurFocusedInputs(restoreScroll = true) {
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === 'INPUT') {
// Don't blur the datepicker's own input
if (
activeElement.closest('.dp__input_wrap') ||
activeElement.closest('.z-datepicker-wrapper')
) {
return;
}
activeElement.blur();
// Restore scroll position after blur (only if requested)
if (restoreScroll && this.savedScrollPosition !== null) {
const modalContent = document.querySelector(
'.v-dialog .modal-content',
);
if (modalContent) {
modalContent.scrollTop = this.savedScrollPosition;
}
}
}
},
attachFocusListeners() {
// Remove old listeners first
this.removeFocusListeners();
// Attach new listeners to autocomplete inputs only (like the mixin)
// Trying to catch all inputs causes issues with event timing
const inputs = document.querySelectorAll('.v-autocomplete input');
let totalListeners = 0;
inputs.forEach((input) => {
// Skip the datepicker's own input
if (input.closest('.dp__input_wrap')) {
return;
}
const listener = this.onInputFocus.bind(this);
this.focusListeners.set(input, listener);
input.addEventListener('focus', listener);
totalListeners++;
});
},
onInputFocus(event) {
const target = event.target;
// Skip if it's the datepicker's own input
if (
target.closest('.dp__input_wrap') ||
target.closest('.z-datepicker-wrapper')
) {
return;
}
// If prevention flag is set, block the focus
if (this.preventInputFocus && this.savedScrollPosition !== null) {
const scrollBefore = document.querySelector(
'.v-dialog .modal-content',
)?.scrollTop;
// Blur the focused element with a slight delay (like the mixin does)
setTimeout(() => {
target.blur();
// Restore scroll position after blur
const modalContent = document.querySelector(
'.v-dialog .modal-content',
);
if (modalContent && this.savedScrollPosition !== null) {
modalContent.scrollTop = this.savedScrollPosition;
const scrollAfter = modalContent.scrollTop;
}
}, 0);
}
},
removeFocusListeners() {
this.focusListeners.forEach((listener, input) => {
input.removeEventListener('focus', listener);
});
this.focusListeners.clear();
},
},
beforeUnmount() {
// Cleanup listeners on component unmount
this.removeFocusListeners();
},
};
</script>
<style scoped lang="sass">
.z-datepicker-wrapper
position: relative
width: 100%
cursor: pointer
.z-datepicker-label
position: absolute
left: 16px
top: 50%
transform: translateY(-50%)
font-size: 16px
color: rgba(0, 0, 0, 0.38)
background: transparent
padding: 0
z-index: 1
pointer-events: none
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1)
opacity: 0
visibility: hidden
&.has-value .z-datepicker-label
opacity: 1
visibility: visible
top: 8px
transform: translateY(0)
font-size: 12px
background: white
padding: 0 4px
left: 13px
&.is-focused .z-datepicker-label
color: rgba(0, 0, 0, 0.87)
&.has-value .z-datepicker-label
color: rgba(0, 0, 0, 0.6)
&.has-value :deep(.dp__input_icon)
top: 33px !important
&.has-value :deep(.dp__input)
padding-top: 20px !important
padding-bottom: 8px !important
:deep(.dp__input)
padding-top: 14px !important
padding-bottom: 14px !important
:deep(.dp__input_wrap)
pointer-events: none
cursor: pointer
:deep(.dp__input)
cursor: pointer
pointer-events: none !important
:deep(.dp__clear_icon)
pointer-events: auto !important
cursor: pointer !important
top: 28px !important
:deep(.dp__input_icon)
top: 27px !important
left: 3px !important
</style>
@@ -0,0 +1,179 @@
<template>
<v-table>
<thead>
<tr>
<th class="text-left">
Kód projektu
</th>
<th class="text-left">Názov</th>
<th class="text-left">Deadline</th>
<th class="text-left">Najbližší neuzavretý</th>
<th class="text-left">Naplánované</th>
<th class="text-left">Zostávajúce</th>
<th class="text-left">Akcia</th>
</tr>
</thead>
<tbody>
<tr
class="cursor-pointer"
v-for="subtask in subtasks"
:key="subtask.id"
@click.stop="editTask(subtask, false)"
>
<td>{{ subtask.project_code }}</td>
<td>{{ subtask.title ?? subtask.master_task }}</td>
<td v-if="subtask.deadline">{{ getDeadline(subtask) }}</td>
<v-tooltip text="Deadline z Mastertasku" location="end">
<template v-slot:activator="{ props }">
<td v-if="!subtask.deadline" v-bind="props">
{{ getDeadline(subtask) }}*
</td>
</template>
</v-tooltip>
<td>{{ displayTimeblock(subtask) }}</td>
<td>{{ subtask.duration }} h</td>
<td>{{ (subtask.duration - subtask.spent_time).toFixed(2) }} h</td>
<td class="task-action">
<div class="d-flex justify-start align-center">
<template v-if="subtask.type == 'office365'">
<img
class="pencil-icon"
:src="eye"
@click.stop="editTask(subtask, false)"
/>
<img class="office-icon" :src="office365" />
</template>
<template v-else>
<img
class="pencil-icon"
:src="pencil"
@click.stop="editTask(subtask, true)"
/>
<img
class="pencil-icon"
:src="eye"
@click.stop="editTask(subtask, false)"
/>
<img
class="trash-icon"
:src="bin"
@click.stop="deleteTask(subtask)"
/>
</template>
</div>
</td>
</tr>
</tbody>
</v-table>
</template>
<script>
import { bin, eye, office365, pencil } from '@/plugins/app/_img';
import { mapGetters } from 'vuex';
import moment from 'moment';
export default {
props: {
subtasks: {
type: Array,
required: true,
},
},
data() {
return {
eye,
bin,
office365,
pencil,
loadingSubtask: {},
};
},
computed: {
...mapGetters('tm', ['mastertasks']),
},
methods: {
editTask(subtask, editMode = false) {
this.$events.emit('show-subtask-modal', { subtask, editMode });
},
deleteTask(subtask) {
if (subtask.type == 'office365')
return this.$events.emit('show-unable-to-edit-office-task-modal', {
mode: 'delete',
});
this.$events.emit('show-delete-task-modal', { subtask });
},
getDeadline(subtask) {
if (subtask?.deadline)
return moment(subtask.deadline).format('DD.MM.yyyy');
return moment(subtask.master_task_deadline).format('DD.MM.yyyy');
},
getSelectedTimeblockDeadline(timeblocks) {
const currentDate = moment();
const unfinishedTimeblocks = timeblocks.filter(
(tb) => tb.is_finished === 0,
);
if (unfinishedTimeblocks.length > 0)
return moment(unfinishedTimeblocks[0].end).format('DD.MM.yyyy, HH:mm');
const upcomingTimeblocks = timeblocks
.filter((tb) => moment(tb.end).isAfter(currentDate))
.sort((a, b) => moment(a.end).diff(moment(b.end)));
if (upcomingTimeblocks.length > 0 && upcomingTimeblocks.is_finished === 0)
return moment(upcomingTimeblocks[0].end).format('DD.MM.yyyy, HH:mm');
return '';
},
displayTimeblock(subtask) {
const timeblockDeadline = this.getSelectedTimeblockDeadline(
subtask.timeblocks,
);
return timeblockDeadline !== '' ? timeblockDeadline : 'Žiadny termín';
},
},
};
</script>
<style lang="sass" scoped>
v-table, th, td
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity))
.office-icon
height: 20px
margin-left: 12px
@media screen and (max-width: 496px)
.task-action
div
display: flex
flex-direction: column
.pencil-icon, .trash-icon
border-radius: 50%
margin: 6px
padding: 6px
.disabled
opacity: 0.5
pointer-events: none
tbody
tr:hover
background: rgba(0,0,0,.1)
</style>
@@ -0,0 +1,164 @@
<template>
<div>
<Z-tasks-search
@search="onSearch"
@employee-selected="onEmployeeSelected"
@update-sort-order="onUpdateSortOrder"
/>
<W-loadable
:content="filteredConceptTasks"
:emptyMessage="
searchQuery.length
? 'Neboli nájdené žiadne úlohy'
: 'Nenašli sa žiadne záznamy'
"
>
<A-concepts-table :subtasks="filteredConceptTasks" />
</W-loadable>
</div>
</template>
<script>
import ZTasksSearch from '@/plugins/tm@overview/_components/z-tasks-search.vue';
import { mapGetters } from 'vuex';
import AConceptsTable from './_components/a-concepts-table.vue';
export default {
components: {
ZTasksSearch,
AConceptsTable,
},
data() {
return {
searchQuery: '',
sortOrder: null,
conceptSubtasks: null,
};
},
async ionViewWillEnter() {
await this.loadConceptTasks();
},
async mounted() {
await this.loadConceptTasks();
this.$events.on('on-subtask-edited', async () => {
await this.loadConceptTasks();
});
this.$events.on('on-subtask-deleted', async () => {
await this.loadConceptTasks();
});
},
unmounted() {
this.$events.off('on-subtask-edited');
this.$events.off('on-subtask-deleted');
},
computed: {
...mapGetters('tm', ['mastertasks']),
filteredConceptTasks() {
if (!this.conceptSubtasks) return null;
let filteredSubtasks = this.conceptSubtasks.filter((subtask) => {
return (
this.filterParams(
this.searchQuery,
subtask.master_task?.name || subtask.title,
) || this.filterParams(this.searchQuery, subtask.project_code)
);
});
//Sorting by name A-Z
if (this.sortOrder === 'nameAsc') {
filteredSubtasks.sort((a, b) => {
const nameA = (a.title || a.master_task || '').toLowerCase();
const nameB = (b.title || b.master_task || '').toLowerCase();
return nameA.localeCompare(nameB);
});
//Sorting by name Z-A
} else if (this.sortOrder === 'nameDesc') {
filteredSubtasks.sort((a, b) => {
const nameA = (a.title || a.master_task || '').toLowerCase();
const nameB = (b.title || b.master_task || '').toLowerCase();
return nameB.localeCompare(nameA);
});
//Sorting by date Newest-Oldest
} else if (this.sortOrder === 'deadlineDesc') {
filteredSubtasks.sort((a, b) => {
const deadlineA = a.deadline
? new Date(a.deadline)
: new Date(this.getMasterTaskDeadline(a));
const deadlineB = b.deadline
? new Date(b.deadline)
: new Date(this.getMasterTaskDeadline(b));
return deadlineB - deadlineA;
});
//Sorting by date Oldest-Newest
} else if (this.sortOrder === 'deadlineAsc') {
filteredSubtasks.sort((a, b) => {
const deadlineA = a.deadline
? new Date(a.deadline)
: new Date(this.getMasterTaskDeadline(a));
const deadlineB = b.deadline
? new Date(b.deadline)
: new Date(this.getMasterTaskDeadline(b));
return deadlineA - deadlineB;
});
}
return filteredSubtasks;
},
},
methods: {
async loadConceptTasks(employeeId = null) {
try {
const params = { type: 'draft' };
if (employeeId) {
params.employee_id = employeeId;
} else {
params.employee_id = this.$store.getters['auth/user'].id;
}
const queryParameters = new URLSearchParams(params).toString();
const { subtasks } = await this.$axios.get_auth(
`v1/subtasks?${queryParameters}`,
);
this.conceptSubtasks = subtasks;
} catch (error) {
this.$toast.error('Nepodarilo sa načítať koncepty!');
if (process.env.NODE_ENV === 'development')
console.log('[tasks-concepts.vue] Error: ', error);
}
},
onSearch(searchQuery) {
this.searchQuery = searchQuery;
},
async onEmployeeSelected(id) {
await this.loadConceptTasks(id);
},
onUpdateSortOrder(newSortOrder) {
this.sortOrder = newSortOrder;
},
filterParams(searchQuery, row) {
if (!row) return false;
return row.toLowerCase().includes(searchQuery.toLowerCase());
},
getMasterTaskDeadline(subtask) {
return subtask.master_task_deadline;
},
},
};
</script>
+46 -8
View File
@@ -273,12 +273,42 @@
/>
</v-card-actions>
<v-btn v-if="!isSaving" class="button primary" @click="save()">{{
subtask.id ? 'UPRAVIŤ' : 'VYTVORIŤ'
}}</v-btn>
<v-btn v-else class="button primary disabled" disabled>{{
$t('Ukladám...')
}}</v-btn>
<template v-if="!subtask.id">
<v-btn class="button primary" :disabled="isSaving" @click="save()"
>VYTVORIŤ</v-btn
>
<v-btn
class="button primary outline mt-2"
@click="save(true)"
:disabled="isSaving"
>ULOŽIŤ KONCEPT</v-btn
>
</template>
<template v-else>
<template v-if="subtask.is_draft">
<v-btn
class="button primary"
:disabled="isSaving"
@click="save(true)"
>ULOŽIŤ A PUBLIKOVAŤ</v-btn
>
<v-btn
class="button primary outline mt-2"
@click="save(true)"
:disabled="isSaving"
>ULOŽIŤ KONCEPT</v-btn
>
</template>
<template v-else>
<v-btn class="button primary" :disabled="isSaving" @click="save()"
>UPRAVIŤ</v-btn
>
</template>
</template>
</v-card>
<v-card v-else height="100%" class="d-flex flex-column scrolling">
@@ -709,7 +739,7 @@ export default {
}
},
async save() {
async save(concept = false) {
this.wasFormValidationTriggered = true;
if (!(await this.validateAllFields())) return;
@@ -741,9 +771,14 @@ export default {
};
});
const payload = { ...this.subtask, timesheets, timeblocks };
if (concept) {
payload.concept = true;
}
const { subtask } = await this.$axios.patch_auth_form(
`v1/subtasks/${this.subtask.id}`,
{ ...this.subtask, timesheets, timeblocks },
payload,
);
localStorage.removeItem('subtaskData');
@@ -775,6 +810,9 @@ export default {
...this.subtask,
employees: validEmployees,
};
if (concept) {
subtaskCopy.is_draft = true;
}
delete subtaskCopy._master_task;
const { subtasks } = await this.$axios.post_auth(
`v1/mastertasks/${this.subtask.master_task_id}/subtasks`,