Compare commits

...

16 Commits

Author SHA1 Message Date
gitea-actions
47f2ab9cd4 chore: bump version to v0.3.29
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
Auto Tag Develop / tag (push) Successful in 6s
2026-04-09 14:35:49 +00:00
Matthieu
36729f8f61 feat(task) : add markdown preview for task description
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:35:41 +02:00
gitea-actions
30b090852d chore: bump version to v0.3.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-09 12:37:35 +00:00
Matthieu
f0c9568521 feat(infra) : persist logs in prod via named volume
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Add lesstime_logs volume for var/log/ persistence across container
restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:34:00 +02:00
gitea-actions
7c37eb58cb chore: bump version to v0.3.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m16s
2026-04-09 09:20:56 +00:00
Matthieu
7a5b8dabff fix : set app title to Lesstime and remove title switch
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:19:20 +02:00
Matthieu
fef563be06 refactor : replace password inputs with MalioInputPassword component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:17:18 +02:00
Matthieu
e14c707dfd fix : replace native select with MalioSelect for sort filter on my-tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:16:02 +02:00
Matthieu
fa7bb27ef5 feat : include collaborator tasks in dashboard, my-tasks, and project filters
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:57:30 +02:00
Matthieu
21e9d2cab4 feat : show collaborators icon on TaskCard and TaskListItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:57:26 +02:00
Matthieu
00ffcb1cf2 feat : add collaborators multi-select to TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:56:53 +02:00
Matthieu
daba09472f feat : add collaborators to Task DTO
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:55:42 +02:00
Matthieu
f3208a481f feat : add collaborators to all MCP task tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:55:36 +02:00
Matthieu
a46542fcdd feat : add Serializer::users() for collaborators
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:54:33 +02:00
Matthieu
1ae2d9ac2c feat : add task_collaborator migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:54:28 +02:00
Matthieu
e41caa9cfe feat : add collaborators ManyToMany on Task entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:53:53 +02:00
26 changed files with 446 additions and 120 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.26'
app.version: '0.3.29'

View File

@@ -10,21 +10,17 @@
input-class="w-full"
/>
<MalioInputText
<MalioInputPassword
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
<div>
<MalioInputText
<MalioInputPassword
v-model="form.tokenSecret"
:label="$t('bookstack.settings.tokenSecret')"
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
{{ $t('bookstack.settings.tokenConfigured') }}

View File

@@ -11,12 +11,10 @@
/>
<div>
<MalioInputText
<MalioInputPassword
v-model="form.token"
:label="$t('gitea.settings.token')"
:placeholder="$t('gitea.settings.tokenPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
{{ $t('gitea.settings.tokenConfigured') }}

View File

@@ -22,11 +22,10 @@
input-class="w-full"
/>
<div>
<MalioInputText
<MalioInputPassword
v-model="form.password"
:label="$t('zimbra.settings.password')"
input-class="w-full"
type="password"
/>
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
{{ $t('zimbra.settings.passwordConfigured') }}

View File

@@ -78,11 +78,17 @@
class="text-blue-500"
size="14"
/>
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="ml-auto h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
class="ml-auto"
:class="task.collaborators?.length ? '' : 'ml-auto'"
/>
<span
v-else

View File

@@ -86,17 +86,25 @@
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
<span
v-else
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
<div class="flex items-center gap-1">
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
<span
v-else
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
</div>
</div>
</div>
</template>

View File

@@ -170,11 +170,46 @@
</div>
</div>
<!-- Collaborators -->
<div v-if="collaboratorOptions.length" class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
<div class="flex flex-wrap gap-2">
<label
v-for="user in collaboratorOptions"
:key="user.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.collaboratorIds.includes(user.value)
? 'bg-primary-500 text-white shadow-sm'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="user.value"
:checked="form.collaboratorIds.includes(user.value)"
@change="toggleCollaborator(user.value)"
/>
{{ user.label }}
</label>
</div>
</div>
<!-- Description -->
<div class="mt-5">
<div class="mb-1 flex items-center justify-between">
<label class="text-sm font-medium text-slate-700">Description</label>
<button
v-if="form.description"
type="button"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="showMarkdownPreview = true"
>
<Icon name="heroicons:eye" class="size-3.5" />
Aperçu MD
</button>
</div>
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="5"
resize="vertical"
:min-resize-height="140"
@@ -182,6 +217,12 @@
/>
</div>
<MarkdownPreviewModal
v-model="showMarkdownPreview"
:content="form.description"
title="Aperçu de la description"
/>
<!-- Documents -->
<TaskDocumentUpload
v-if="isEditing && task && isAdmin"
@@ -517,7 +558,7 @@ const isOpen = computed({
})
function close() {
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value || showMarkdownPreview.value) return
isOpen.value = false
}
@@ -525,6 +566,7 @@ const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning'>('details')
const showMarkdownPreview = ref(false)
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
@@ -544,6 +586,7 @@ const form = reactive({
effortId: null as number | null,
priorityId: null as number | null,
assigneeId: null as number | null,
collaboratorIds: [] as number[],
groupId: null as number | null,
tagIds: [] as number[],
clientTicketId: null as number | null,
@@ -586,6 +629,18 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const collaboratorOptions = computed(() =>
props.users
.filter(u => u.id !== form.assigneeId)
.map(u => ({ label: u.username, value: u.id }))
)
watch(() => form.assigneeId, (newAssigneeId) => {
if (newAssigneeId) {
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
}
})
const groupOptions = computed(() => {
let filtered = props.groups.filter(g => !g.archived)
if (showProjectSelect.value && form.projectId) {
@@ -624,6 +679,12 @@ function toggleTag(id: number) {
}
}
function toggleCollaborator(userId: number) {
const idx = form.collaboratorIds.indexOf(userId)
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
else form.collaboratorIds.push(userId)
}
const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
@@ -648,6 +709,7 @@ function populateForm(task: Task | null) {
form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null
@@ -694,6 +756,7 @@ function populateForm(task: Task | null) {
form.effortId = null
form.priorityId = null
form.assigneeId = null
form.collaboratorIds = []
form.groupId = null
form.tagIds = []
form.clientTicketId = null
@@ -906,6 +969,7 @@ async function handleSubmit() {
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),

View File

@@ -10,15 +10,7 @@
@click="ui.openMobileSidebar()"
/>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
variant="ghost"
icon-size="18"
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
@click="toggleTitle"
/>
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
</div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<MalioButtonIcon
@@ -66,13 +58,6 @@ defineProps<{
const auth = useAuthStore()
const ui = useUiStore()
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
function toggleTitle() {
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
localStorage.setItem('appTitle', appTitle.value)
}
async function handleLogout() {
await auth.logout()
await navigateTo('/login')

View File

@@ -0,0 +1,75 @@
<template>
<Teleport to="body">
<Transition name="md-preview" appear>
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="emit('update:modelValue', false)"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(80vh, 700px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h3 class="text-lg font-semibold text-slate-800">
{{ title }}
</h3>
<button
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
@click="emit('update:modelValue', false)"
>
<Icon name="heroicons:x-mark" class="size-5" />
</button>
</div>
<!-- Body -->
<div class="overflow-y-auto px-6 py-4">
<div
v-if="content"
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100"
v-html="renderedHtml"
/>
<p v-else class="text-sm italic text-slate-400">
Aucune description
</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { marked } from 'marked'
const props = defineProps<{
modelValue: boolean
content: string
title?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const renderedHtml = computed(() => {
if (!props.content) return ''
return marked.parse(props.content, { async: false }) as string
})
</script>
<style scoped>
.md-preview-enter-active,
.md-preview-leave-active {
transition: opacity 0.2s ease;
}
.md-preview-enter-from,
.md-preview-leave-to {
opacity: 0;
}
</style>

View File

@@ -8,12 +8,11 @@
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true"
/>
<MalioInputText
<MalioInputPassword
v-model="form.password"
label="Mot de passe"
input-class="w-full"
type="password"
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
@blur="touched.password = true"
/>

View File

@@ -7,13 +7,15 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.2.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/typography": "^0.5.19",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"marked": "^18.0.0",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
@@ -2212,9 +2214,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.2.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.0/layer-ui-1.2.0.tgz",
"integrity": "sha512-/D/p7Tz5t8xsZ+qL4kwBs2XXA/yNJpwF5C8pbSrz06Z8Je/Yut2J4KT1YpPHcfyFFE3TB8TpV0Okg/29aN6Ggg==",
"version": "1.2.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.3/layer-ui-1.2.3.tgz",
"integrity": "sha512-5nRnBzRkXfs3PfKwKl6sH2ikrmSK7lTifcd0TX1QZP3rFRVRTgcT6mrsrpsbR9PwI27OeCNm0X6d0Ii92Rq7Yg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -5301,6 +5303,31 @@
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
"license": "CC0-1.0"
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -9705,6 +9732,18 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/maska": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",

View File

@@ -11,13 +11,15 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.2.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/typography": "^0.5.19",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"marked": "^18.0.0",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",

View File

@@ -172,7 +172,10 @@ const totalHoursThisWeek = computed(() =>
)
const myTasks = computed(() =>
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
tasks.value.filter(t =>
t.assignee?.id === auth.user?.id
|| t.collaborators?.some(c => c.id === auth.user?.id)
)
)
const myTasksDone = computed(() =>

View File

@@ -17,18 +17,12 @@
v-model="username"
/>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<MalioInputPassword
v-model="password"
label="Mot de passe"
autocomplete="current-password"
input-class="w-full"
/>
<MalioButton
label="Se connecter"

View File

@@ -51,8 +51,9 @@ const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// Sort
type SortOption = 'default' | 'deadline' | 'scheduledStart'
const sortBy = ref<SortOption>('default')
const SORT_DEADLINE = 1
const SORT_SCHEDULED = 2
const sortById = ref<number | null>(null)
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
@@ -106,6 +107,11 @@ const assigneeOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
const sortOptions = computed(() => [
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
])
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
@@ -140,33 +146,43 @@ async function loadReferenceData() {
}
async function loadTasks() {
const params: Record<string, string | number | boolean | string[]> = {
const baseParams: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (selectedAssigneeId.value) {
params.assignee = `/api/users/${selectedAssigneeId.value}`
}
if (selectedProjectId.value) {
params.project = `/api/projects/${selectedProjectId.value}`
baseParams.project = `/api/projects/${selectedProjectId.value}`
}
if (selectedGroupId.value) {
params.group = `/api/task_groups/${selectedGroupId.value}`
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
}
if (selectedPriorityId.value) {
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
}
if (selectedEffortId.value) {
params.effort = `/api/task_efforts/${selectedEffortId.value}`
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
}
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
if (sortBy.value === 'deadline') {
params['order[deadline]'] = 'asc'
} else if (sortBy.value === 'scheduledStart') {
params['order[scheduledStart]'] = 'asc'
if (sortById.value === SORT_DEADLINE) {
baseParams['order[deadline]'] = 'asc'
} else if (sortById.value === SORT_SCHEDULED) {
baseParams['order[scheduledStart]'] = 'asc'
}
if (selectedAssigneeId.value) {
const userIri = `/api/users/${selectedAssigneeId.value}`
const [assigneeTasks, collabTasks] = await Promise.all([
taskService.getFiltered({ ...baseParams, assignee: userIri }),
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
])
const map = new Map<number, Task>()
for (const t of assigneeTasks) map.set(t.id, t)
for (const t of collabTasks) map.set(t.id, t)
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
} else {
tasks.value = await taskService.getFiltered(baseParams)
}
tasks.value = await taskService.getFiltered(params)
}
async function loadAll() {
@@ -180,7 +196,7 @@ async function loadAll() {
// Watch filters and sort to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
() => { loadTasks() },
)
@@ -400,17 +416,15 @@ onMounted(async () => {
text-field="text-sm"
text-value="text-sm"
/>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
<select
v-model="sortBy"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
</select>
</div>
<MalioSelect
v-model="sortById"
:options="sortOptions"
:label="$t('myTasks.sortBy')"
:empty-option-label="$t('myTasks.sortDefault')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>

View File

@@ -298,7 +298,10 @@ const filteredTasks = computed(() => {
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
}
if (selectedAssigneeId.value) {
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
result = result.filter(t =>
t.assignee?.id === selectedAssigneeId.value
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
)
}
if (selectedStatusId.value) {
result = result.filter(t => t.status?.id === selectedStatusId.value)

View File

@@ -17,6 +17,7 @@ export type Task = {
effort: TaskEffort | null
priority: TaskPriority | null
assignee: UserData | null
collaborators: UserData[]
group: TaskGroup | null
project: Project | null
tags: TaskTag[]
@@ -55,6 +56,7 @@ export type TaskWrite = {
effort: string | null
priority: string | null
assignee: string | null
collaborators?: string[]
group: string | null
project: string
tags: string[]

View File

@@ -1,7 +1,9 @@
import type {Config} from 'tailwindcss'
import typography from '@tailwindcss/typography'
export default <Partial<Config>>{
darkMode: 'class',
plugins: [typography],
theme: {
extend: {
fontFamily: {

View File

@@ -8,6 +8,10 @@ services:
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
- lesstime_logs:/var/www/html/var/log
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
volumes:
lesstime_logs:

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260409075411 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE task_collaborator (task_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (task_id, user_id))');
$this->addSql('CREATE INDEX IDX_A8FC6C518DB60186 ON task_collaborator (task_id)');
$this->addSql('CREATE INDEX IDX_A8FC6C51A76ED395 ON task_collaborator (user_id)');
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C518DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C51A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C518DB60186');
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C51A76ED395');
$this->addSql('DROP TABLE task_collaborator');
}
}

View File

@@ -38,7 +38,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
denormalizationContext: ['groups' => ['task:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'collaborators' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
@@ -85,6 +85,16 @@ class Task
#[Groups(['task:read', 'task:write'])]
private ?User $assignee = null;
/** @var Collection<int, User> */
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(
name: 'task_collaborator',
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
)]
#[Groups(['task:read', 'task:write'])]
private Collection $collaborators;
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
@@ -152,8 +162,9 @@ class Task
public function __construct()
{
$this->tags = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->tags = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->collaborators = new ArrayCollection();
}
public function getId(): ?int
@@ -245,6 +256,28 @@ class Task
return $this;
}
/** @return Collection<int, User> */
public function getCollaborators(): Collection
{
return $this->collaborators;
}
public function addCollaborator(User $user): static
{
if (!$this->collaborators->contains($user)) {
$this->collaborators->add($user);
}
return $this;
}
public function removeCollaborator(User $user): static
{
$this->collaborators->removeElement($user);
return $this;
}
public function getGroup(): ?TaskGroup
{
return $this->group;
@@ -434,4 +467,15 @@ class Task
;
}
}
#[Assert\Callback]
public function validateCollaborators(ExecutionContextInterface $context): void
{
if (null !== $this->assignee && $this->collaborators->contains($this->assignee)) {
$context->buildViolation('The assignee cannot also be a collaborator.')
->atPath('collaborators')
->addViolation()
;
}
}
}

View File

@@ -134,6 +134,19 @@ final class Serializer
];
}
/**
* @param Collection<int, User> $users
*
* @return list<array{id: ?int, username: ?string}>
*/
public static function users(Collection $users): array
{
return $users->map(fn (User $u) => [
'id' => $u->getId(),
'username' => $u->getUsername(),
])->toArray();
}
/**
* @return null|array{id: ?int, title: ?string, color: ?string}
*/

View File

@@ -51,6 +51,7 @@ class CreateTaskTool
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?array $collaboratorIds = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
@@ -116,6 +117,18 @@ class CreateTaskTool
$task->addTag($tag);
}
}
if (null !== $collaboratorIds) {
foreach ($collaboratorIds as $collaboratorId) {
$collaborator = $this->userRepository->find($collaboratorId);
if (null === $collaborator) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
}
if (null !== $assigneeId && $collaboratorId === $assigneeId) {
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
}
$task->addCollaborator($collaborator);
}
}
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
@@ -147,6 +160,7 @@ class CreateTaskTool
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()),

View File

@@ -34,19 +34,20 @@ class GetTaskTool
}
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::statusFull($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::group($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tagsWithColor($task->getTags()),
'documents' => Serializer::documents($task->getDocuments()),
'archived' => $task->isArchived(),
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::statusFull($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::group($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tagsWithColor($task->getTags()),
'documents' => Serializer::documents($task->getDocuments()),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -10,7 +10,7 @@ use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, collaborator, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
class ListTasksTool
{
public function __construct(
@@ -22,6 +22,7 @@ class ListTasksTool
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $collaboratorId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
@@ -38,6 +39,7 @@ class ListTasksTool
->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g')
@@ -57,6 +59,9 @@ class ListTasksTool
if (null !== $assigneeId) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
}
if (null !== $collaboratorId) {
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
}
if (null !== $priorityId) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
}
@@ -75,17 +80,18 @@ class ListTasksTool
}
return json_encode(array_map(fn ($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'assignee' => Serializer::user($task->getAssignee()),
'effort' => Serializer::effort($task->getEffort()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'effort' => Serializer::effort($task->getEffort()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
], array_values($tasks)));
}
}

View File

@@ -48,6 +48,7 @@ class UpdateTaskTool
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?array $collaboratorIds = null,
?bool $archived = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
@@ -118,6 +119,22 @@ class UpdateTaskTool
$task->addTag($tag);
}
}
if (null !== $collaboratorIds) {
foreach ($task->getCollaborators()->toArray() as $existing) {
$task->removeCollaborator($existing);
}
$assignee = $task->getAssignee();
foreach ($collaboratorIds as $collaboratorId) {
$collaborator = $this->userRepository->find($collaboratorId);
if (null === $collaborator) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
}
if (null !== $assignee && $collaborator->getId() === $assignee->getId()) {
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
}
$task->addCollaborator($collaborator);
}
}
if (null !== $archived) {
$task->setArchived($archived);
}
@@ -147,6 +164,7 @@ class UpdateTaskTool
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),