Compare commits

...

29 Commits

Author SHA1 Message Date
gitea-actions
b25be8fd6a chore: bump version to v0.3.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 43s
2026-05-06 13:58:46 +00:00
Matthieu
3e6b0e877a fix(time-tracking) : filtres projet/tag server-side et vue liste au mois
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Pousse les filtres projet et tag a l'API (au lieu d'un filtrage client-side
  partiel sur la page courante) pour eviter les resultats incomplets en cas
  de pagination
- Ajoute les watchers selectedProjectId/selectedTagId qui declenchent un reload
- Mode liste : navigation et plage de chargement passent a 1 mois (au lieu
  d'une fenetre de 7 jours qui rendait le mode liste inutilisable)
- Renomme l'option vide du filtre User en "Tous" (etait "User", ambigu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:18 +02:00
Matthieu
9f3fc05a52 fix(project) : masquer le filtre status en mode kanban
En mode kanban, selectionner un statut dans le filtre Status vidait toutes
les autres colonnes ET le backlog (tasks?.status?.id !== selectedId) : le
filtre etait redondant avec les colonnes et cassait la vue.

Conditionne l'affichage du filtre Status a viewMode === 'list' et reset le
filtre lors du retour en kanban.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:09 +02:00
Matthieu
4c3721b6ac fix(dashboard) : appliquer le filtre user aux KPIs et charts de taches
Avant, seul le KPI "Heures sur la periode" reagissait au filtre Utilisateur ;
"Taches totales", "Mes taches actives" et tous les graphiques tache restaient
inchanges. Le computed tasks ne filtrait que par projet, et myTasks etait
hardcode sur auth.user.id (cf ticket LST40).

Ajoute un effectiveUserId (selectedUser ?? auth.user) et applique le filtre
user a tasks pour propager dans tous les charts et KPIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:02 +02:00
Matthieu
06d733f88e docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 08:49:20 +02:00
gitea-actions
258c6e9c17 chore: bump version to v0.3.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-05-04 18:54:31 +00:00
feffe63019 fix(rich-text) : nettoyer deps TipTap obsolètes et fixer interop CJS
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Le rich text editor étant désormais fourni par @malio/layer-ui, les
dépendances @tiptap/* et tiptap-markdown directes dans Lesstime
(héritage de l'ancien éditeur local) ne servent plus et causaient un
doublon de tiptap-markdown (0.8.10 + 0.9.0) qui faisait planter
l'init Nuxt avec une erreur d'export default sur markdown-it-task-lists.

- Suppression des deps @tiptap/extension-link, @tiptap/extension-placeholder,
  @tiptap/pm, @tiptap/starter-kit, @tiptap/vue-3, tiptap-markdown
- Ajout de markdown-it-task-lists à vite.optimizeDeps.include pour
  forcer Vite à gérer correctement l'interop CJS du module

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:54:18 +02:00
34ba554fba chore : bump @malio/layer-ui à 1.4.8
Inclut les couleurs de texte et surlignage façon Jira dans
<MalioInputRichText> (toolbar étendue avec popover en palette).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:47:17 +02:00
b2cc6e96e1 fix(rich-text) : strip HTML pour les contextes plain-text
Avec MalioInputRichText qui émet désormais du HTML par défaut,
plusieurs points d'affichage rendaient les balises brutes au
lieu du texte. Ajoute un helper stripRichText() (frontend) et
descriptionToPlainText() (backend) pour neutraliser ces cas.

- TimeEntryList : strip avant truncate dans la liste des time
  entries.
- ProjectGroupTab : strip dans la cellule description du
  tableau des groupes.
- CalDavService : strip_tags + html_entity_decode avant injection
  dans le DESCRIPTION VEVENT/VTODO iCal (sinon Outlook/Apple
  Calendar affichaient les <p>...</p> à l'utilisateur).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:55:23 +02:00
2a68d2f9c6 feat(rich-text) : migrer vers MalioInputRichText (layer-ui 1.4.7)
Remplace les éditeurs markdown locaux et les textareas
description par <MalioInputRichText> (TipTap v3 + StarterKit +
tiptap-markdown) du paquet @malio/layer-ui.

Sites migrés :
- TaskModal (description tâche)
- TaskGroupDrawer (description groupe de tâches)
- TimeEntryDrawer (description time entry)
- ClientTicketDetailModal (édition + lecture seule)
- ProjectClientTickets (panneau admin lecture seule)
- new-ticket (formulaire portail client)
- client-tickets (vue admin lecture seule)

Stockage en BDD inchangé : le markdown existant est parsé à
l'ouverture, le composant émet du HTML par défaut sur les
sauvegardes (migration lazy au fil des éditions).

Bumpe @malio/layer-ui de ^1.2.3 à ^1.4.7 et ajoute les
dépendances TipTap utilisées par le composant.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:57 +02:00
2898b22440 fix(infra) : monter nginx.conf comme default.conf
Avant, deux fichiers conf cohabitaient dans /etc/nginx/conf.d/
(default.conf de l'image + lesstime.conf monté), tous deux écoutant
sur :80 server_name localhost. Nginx prenait default.conf
(ordre alphabétique), ce qui faisait répondre 404 à toutes les
requêtes /api/* — donc pas de header CORS, donc le navigateur
remontait une erreur CORS trompeuse côté front.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:43 +02:00
gitea-actions
f1fd80d9ac chore: bump version to v0.3.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m43s
2026-04-10 08:18:54 +00:00
Matthieu
24e3e8e989 fix(ui) : fix code block rendering in markdown preview
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Code blocks (triple backticks) had broken styling because prose-code
styles (light background, padding) were also applied to <code> inside
<pre>, conflicting with the dark pre background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:18:40 +02:00
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
41 changed files with 1402 additions and 237 deletions

View File

@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal` - Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions - Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
### Composants UI
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
### MCP Server ### MCP Server
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences - 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production` - API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false - ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH) - TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
## Delegation Codex
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.

View File

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

View File

@@ -41,7 +41,7 @@ services:
- "8082:80" - "8082:80"
volumes: volumes:
- ./:/var/www/html:ro - ./:/var/www/html:ro
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/lesstime.conf:ro - ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped restart: unless-stopped
db: db:
image: postgres:16-alpine image: postgres:16-alpine

View File

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

View File

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

View File

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

View File

@@ -66,14 +66,10 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700"> <MalioInputRichText
{{ $t('clientTicket.description') }}
</label>
<textarea
v-model="editForm.description" v-model="editForm.description"
rows="5" :label="$t('clientTicket.description')"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" min-height="180px"
style="resize: vertical; min-height: 140px; max-height: 500px"
/> />
</div> </div>
@@ -129,7 +125,13 @@
<!-- Description --> <!-- Description -->
<div class="mt-4"> <div class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p> <p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p> <MalioInputRichText
v-if="ticket.description"
:model-value="ticket.description"
:editable="false"
group-class="mt-1"
/>
<p v-else class="mt-1 text-sm italic text-neutral-400"></p>
</div> </div>
<!-- URL (if bug) --> <!-- URL (if bug) -->

View File

@@ -116,7 +116,12 @@
<!-- Expanded details --> <!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3"> <div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p> <MalioInputRichText
v-if="ticket.description"
:model-value="ticket.description"
:editable="false"
/>
<p v-else class="text-sm italic text-neutral-400"></p>
<div v-if="ticket.url" class="mt-2"> <div v-if="ticket.url" class="mt-2">
<a <a
:href="ticket.url" :href="ticket.url"

View File

@@ -36,7 +36,7 @@
/> />
</template> </template>
<template #cell-description="{ item }"> <template #cell-description="{ item }">
{{ item.description ?? '—' }} {{ stripRichText(item.description) || '—' }}
</template> </template>
<template #actions="{ item }"> <template #actions="{ item }">
<MalioButton <MalioButton
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
projectId: number projectId: number

View File

@@ -78,11 +78,17 @@
class="text-blue-500" class="text-blue-500"
size="14" 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 <UserAvatar
v-if="task.assignee" v-if="task.assignee"
:user="task.assignee" :user="task.assignee"
size="xs" size="xs"
class="ml-auto" :class="task.collaborators?.length ? '' : 'ml-auto'"
/> />
<span <span
v-else v-else

View File

@@ -8,10 +8,10 @@
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''" :error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true" @blur="touched.title = true"
/> />
<MalioInputTextArea <MalioInputRichText
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" min-height="120px"
/> />
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />

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'" :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)" @click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
/> />
<UserAvatar <div class="flex items-center gap-1">
v-if="task.assignee" <Icon
:user="task.assignee" v-if="task.collaborators?.length"
size="xs" name="mdi:account-group"
/> class="h-4 w-4 text-neutral-400"
<span :title="task.collaborators.map(c => c.username).join(', ')"
v-else />
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400" <UserAvatar
> v-if="task.assignee"
<Icon name="mdi:account-outline" size="14" /> :user="task.assignee"
</span> 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>
</div> </div>
</template> </template>

View File

@@ -170,15 +170,36 @@
</div> </div>
</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 --> <!-- Description -->
<div class="mt-5"> <div class="mt-5">
<MalioInputTextArea <MalioInputRichText
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="5" min-height="180px"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
/> />
</div> </div>
@@ -544,6 +565,7 @@ const form = reactive({
effortId: null as number | null, effortId: null as number | null,
priorityId: null as number | null, priorityId: null as number | null,
assigneeId: null as number | null, assigneeId: null as number | null,
collaboratorIds: [] as number[],
groupId: null as number | null, groupId: null as number | null,
tagIds: [] as number[], tagIds: [] as number[],
clientTicketId: null as number | null, clientTicketId: null as number | null,
@@ -586,6 +608,18 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) 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(() => { const groupOptions = computed(() => {
let filtered = props.groups.filter(g => !g.archived) let filtered = props.groups.filter(g => !g.archived)
if (showProjectSelect.value && form.projectId) { if (showProjectSelect.value && form.projectId) {
@@ -624,6 +658,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(() => [ const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') }, { value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') }, { value: 'tuesday', label: t('tasks.planning.days.tue') },
@@ -648,6 +688,7 @@ function populateForm(task: Task | null) {
form.effortId = task.effort?.id ?? null form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null form.assigneeId = task.assignee?.id ?? null
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
form.groupId = task.group?.id ?? null form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id) form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null form.clientTicketId = task.clientTicket?.id ?? null
@@ -694,6 +735,7 @@ function populateForm(task: Task | null) {
form.effortId = null form.effortId = null
form.priorityId = null form.priorityId = null
form.assigneeId = null form.assigneeId = null
form.collaboratorIds = []
form.groupId = null form.groupId = null
form.tagIds = [] form.tagIds = []
form.clientTicketId = null form.clientTicketId = null
@@ -906,6 +948,7 @@ async function handleSubmit() {
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null, effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null, priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : 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, group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${resolvedProjectId.value}`, project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`), tags: form.tagIds.map(id => `/api/task_tags/${id}`),

View File

@@ -11,14 +11,11 @@
/> />
</div> </div>
<div> <MalioInputRichText
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label> v-model="form.description"
<textarea label="Description"
v-model="form.description" min-height="120px"
rows="3" />
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>

View File

@@ -33,8 +33,8 @@
</div> </div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500"> <div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span v-if="entry.project">{{ entry.project.name }}</span> <span v-if="entry.project">{{ entry.project.name }}</span>
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span> <span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
<span v-if="entry.description" class="truncate">{{ entry.description }}</span> <span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
</div> </div>
</div> </div>
@@ -68,6 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]

View File

@@ -10,15 +10,7 @@
@click="ui.openMobileSidebar()" @click="ui.openMobileSidebar()"
/> />
<div class="hidden items-center gap-2 lg:flex"> <div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1> <h1 class="text-lg font-bold tracking-tight">Lesstime</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"
/>
</div> </div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8"> <div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<MalioButtonIcon <MalioButtonIcon
@@ -66,13 +58,6 @@ defineProps<{
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() 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() { async function handleLogout() {
await auth.logout() await auth.logout()
await navigateTo('/login') 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 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
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' : ''" :error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true" @blur="touched.username = true"
/> />
<MalioInputText <MalioInputPassword
v-model="form.password" v-model="form.password"
label="Mot de passe" label="Mot de passe"
input-class="w-full" input-class="w-full"
type="password" :hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''" :error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
@blur="touched.password = true" @blur="touched.password = true"
/> />

View File

@@ -37,6 +37,9 @@ export default defineNuxtConfig({
}, },
}, },
}, },
optimizeDeps: {
include: ['markdown-it-task-lists'],
},
}, },
toast: { toast: {
settings: { settings: {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek' selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
) )
// ── Filtered data (client-side project filter) ── // ── Filtered data (client-side project + user filter) ──
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
const tasks = computed(() => { const tasks = computed(() => {
if (!selectedProjectId.value) return allTasks.value let result = allTasks.value
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value) if (selectedProjectId.value) {
result = result.filter(t => t.project?.id === selectedProjectId.value)
}
if (selectedUserId.value) {
result = result.filter(t =>
t.assignee?.id === selectedUserId.value
|| t.collaborators?.some(c => c.id === selectedUserId.value),
)
}
return result
}) })
const timeEntries = computed(() => { const timeEntries = computed(() => {
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
) )
const myTasks = computed(() => const myTasks = computed(() =>
tasks.value.filter(t => t.assignee?.id === auth.user?.id) tasks.value.filter(t =>
t.assignee?.id === effectiveUserId.value
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
)
) )
const myTasksDone = computed(() => const myTasksDone = computed(() =>

View File

@@ -17,18 +17,12 @@
v-model="username" v-model="username"
/> />
<div> <MalioInputPassword
<label class="text-sm font-semibold text-neutral-700" for="password"> v-model="password"
Mot de passe label="Mot de passe"
</label> autocomplete="current-password"
<input input-class="w-full"
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>
<MalioButton <MalioButton
label="Se connecter" label="Se connecter"

View File

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

View File

@@ -37,15 +37,10 @@
<!-- Description --> <!-- Description -->
<div class="mt-4"> <div class="mt-4">
<MalioInputTextArea <MalioInputRichText
v-model="form.description" v-model="form.description"
:label="$t('clientTicket.description')" :label="$t('clientTicket.description')"
:size="5" min-height="180px"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/> />
</div> </div>

View File

@@ -84,7 +84,12 @@
<!-- Expanded details --> <!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3"> <div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p> <MalioInputRichText
v-if="ticket.description"
:model-value="ticket.description"
:editable="false"
/>
<p v-else class="text-sm italic text-neutral-400"></p>
<div v-if="ticket.url" class="mt-2"> <div v-if="ticket.url" class="mt-2">
<a <a
:href="ticket.url" :href="ticket.url"

View File

@@ -61,6 +61,7 @@
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-if="viewMode === 'list'"
v-model="selectedStatusId" v-model="selectedStatusId"
:options="statusFilterOptions" :options="statusFilterOptions"
label="Status" label="Status"
@@ -258,6 +259,12 @@ const selectedStatusId = ref<number | null>(null)
const selectedPriorityId = ref<number | null>(null) const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null) const selectedEffortId = ref<number | null>(null)
const viewMode = ref<'kanban' | 'list'>('kanban') const viewMode = ref<'kanban' | 'list'>('kanban')
watch(viewMode, (mode) => {
if (mode === 'kanban') {
selectedStatusId.value = null
}
})
const selectedTaskIds = reactive(new Set<number>()) const selectedTaskIds = reactive(new Set<number>())
const dragOverStatusId = ref<number | null>(null) const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0) const dragCounter = ref(0)
@@ -298,7 +305,10 @@ const filteredTasks = computed(() => {
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value)) result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
} }
if (selectedAssigneeId.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) { if (selectedStatusId.value) {
result = result.filter(t => t.status?.id === selectedStatusId.value) result = result.filter(t => t.status?.id === selectedStatusId.value)

View File

@@ -56,7 +56,7 @@
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
label="User" label="User"
empty-option-label="User" empty-option-label="Tous"
/> />
</div> </div>
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0 pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
} }
const filteredEntries = computed(() => { const filteredEntries = computed(() => entries.value)
let result = entries.value
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
return result
})
function getMonday(d: Date): Date { function getMonday(d: Date): Date {
const date = new Date(d) const date = new Date(d)
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
function navigatePrev() { function navigatePrev() {
const d = new Date(startDate.value) const d = new Date(startDate.value)
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7)) if (viewMode.value === 'day') {
startDate.value = viewMode.value === 'day' ? d : getMonday(d) d.setDate(d.getDate() - 1)
startDate.value = d
} else if (viewMode.value === 'list') {
d.setMonth(d.getMonth() - 1)
d.setDate(1)
d.setHours(0, 0, 0, 0)
startDate.value = d
} else {
d.setDate(d.getDate() - 7)
startDate.value = getMonday(d)
}
loadEntries() loadEntries()
} }
function navigateNext() { function navigateNext() {
const d = new Date(startDate.value) const d = new Date(startDate.value)
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7)) if (viewMode.value === 'day') {
startDate.value = viewMode.value === 'day' ? d : getMonday(d) d.setDate(d.getDate() + 1)
startDate.value = d
} else if (viewMode.value === 'list') {
d.setMonth(d.getMonth() + 1)
d.setDate(1)
d.setHours(0, 0, 0, 0)
startDate.value = d
} else {
d.setDate(d.getDate() + 7)
startDate.value = getMonday(d)
}
loadEntries() loadEntries()
} }
@@ -359,12 +370,20 @@ async function onExport(params: {
async function loadEntries() { async function loadEntries() {
const end = new Date(startDate.value) const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7)) if (viewMode.value === 'day') {
end.setDate(end.getDate() + 1)
} else if (viewMode.value === 'list') {
end.setMonth(end.getMonth() + 1)
} else {
end.setDate(end.getDate() + 7)
}
entries.value = await timeEntryService.getByDateRange({ entries.value = await timeEntryService.getByDateRange({
after: startDate.value.toISOString(), after: startDate.value.toISOString(),
before: end.toISOString(), before: end.toISOString(),
user: selectedUserId.value ?? undefined, user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tag: selectedTagId.value ?? undefined,
}) })
} }
@@ -400,11 +419,20 @@ onMounted(async () => {
watch(viewMode, () => { watch(viewMode, () => {
selectedDateFilter.value = null selectedDateFilter.value = null
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value) if (viewMode.value === 'day') {
// keep current date
} else if (viewMode.value === 'list') {
const d = new Date(startDate.value)
d.setDate(1)
d.setHours(0, 0, 0, 0)
startDate.value = d
} else {
startDate.value = getMonday(startDate.value)
}
loadEntries() loadEntries()
}) })
watch(selectedUserId, () => { watch([selectedUserId, selectedProjectId, selectedTagId], () => {
loadEntries() loadEntries()
}) })

View File

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

View File

@@ -9,7 +9,8 @@ export function useTimeEntryService() {
after: string after: string
before: string before: string
user?: number user?: number
types?: number[] project?: number
tag?: number
}): Promise<TimeEntry[]> { }): Promise<TimeEntry[]> {
const query: Record<string, unknown> = { const query: Record<string, unknown> = {
'startedAt[after]': params.after, 'startedAt[after]': params.after,
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
if (params.user) { if (params.user) {
query.user = `/api/users/${params.user}` query.user = `/api/users/${params.user}`
} }
if (params.project) {
query.project = `/api/projects/${params.project}`
}
if (params.tag) {
query['tags[]'] = `/api/task_tags/${params.tag}`
}
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query) const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
return extractHydraMembers(data) return extractHydraMembers(data)
} }

View File

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

View File

@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo` return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
} }
export function stripRichText(value: string | null | undefined): string {
if (!value) return ''
return value
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, '\'')
.replace(/\s+/g, ' ')
.trim()
}

View File

@@ -8,6 +8,10 @@ services:
volumes: volumes:
- ./config/jwt:/var/www/html/config/jwt:ro - ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads - ./uploads:/var/www/html/var/uploads
- lesstime_logs:/var/www/html/var/log
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped 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']], denormalizationContext: ['groups' => ['task:write']],
order: ['id' => 'DESC'], 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(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])] #[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])] #[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
@@ -85,6 +85,16 @@ class Task
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private ?User $assignee = null; 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\ManyToOne(targetEntity: TaskGroup::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
@@ -152,8 +162,9 @@ class Task
public function __construct() public function __construct()
{ {
$this->tags = new ArrayCollection(); $this->tags = new ArrayCollection();
$this->documents = new ArrayCollection(); $this->documents = new ArrayCollection();
$this->collaborators = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -245,6 +256,28 @@ class Task
return $this; 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 public function getGroup(): ?TaskGroup
{ {
return $this->group; 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} * @return null|array{id: ?int, title: ?string, color: ?string}
*/ */

View File

@@ -51,6 +51,7 @@ class CreateTaskTool
?int $assigneeId = null, ?int $assigneeId = null,
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
?array $collaboratorIds = null,
?string $scheduledStart = null, ?string $scheduledStart = null,
?string $scheduledEnd = null, ?string $scheduledEnd = null,
?string $deadline = null, ?string $deadline = null,
@@ -116,6 +117,18 @@ class CreateTaskTool
$task->addTag($tag); $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) { if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart)); $task->setScheduledStart(new DateTimeImmutable($scheduledStart));
} }
@@ -147,6 +160,7 @@ class CreateTaskTool
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()), 'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()), 'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project), 'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()), 'tags' => Serializer::tags($task->getTags()),

View File

@@ -34,19 +34,20 @@ class GetTaskTool
} }
return json_encode([ return json_encode([
'id' => $task->getId(), 'id' => $task->getId(),
'number' => $task->getNumber(), 'number' => $task->getNumber(),
'title' => $task->getTitle(), 'title' => $task->getTitle(),
'description' => $task->getDescription(), 'description' => $task->getDescription(),
'status' => Serializer::statusFull($task->getStatus()), 'status' => Serializer::statusFull($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()), 'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::group($task->getGroup()), 'collaborators' => Serializer::users($task->getCollaborators()),
'project' => Serializer::projectRef($task->getProject()), 'group' => Serializer::group($task->getGroup()),
'tags' => Serializer::tagsWithColor($task->getTags()), 'project' => Serializer::projectRef($task->getProject()),
'documents' => Serializer::documents($task->getDocuments()), 'tags' => Serializer::tagsWithColor($task->getTags()),
'archived' => $task->isArchived(), '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\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException; 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 class ListTasksTool
{ {
public function __construct( public function __construct(
@@ -22,6 +22,7 @@ class ListTasksTool
?int $projectId = null, ?int $projectId = null,
?int $statusId = null, ?int $statusId = null,
?int $assigneeId = null, ?int $assigneeId = null,
?int $collaboratorId = null,
?int $priorityId = null, ?int $priorityId = null,
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
@@ -38,6 +39,7 @@ class ListTasksTool
->leftJoin('t.status', 's')->addSelect('s') ->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p') ->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a') ->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
->leftJoin('t.project', 'pr')->addSelect('pr') ->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e') ->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g') ->leftJoin('t.group', 'g')->addSelect('g')
@@ -57,6 +59,9 @@ class ListTasksTool
if (null !== $assigneeId) { if (null !== $assigneeId) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
} }
if (null !== $collaboratorId) {
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
}
if (null !== $priorityId) { if (null !== $priorityId) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
} }
@@ -75,17 +80,18 @@ class ListTasksTool
} }
return json_encode(array_map(fn ($task) => [ return json_encode(array_map(fn ($task) => [
'id' => $task->getId(), 'id' => $task->getId(),
'number' => $task->getNumber(), 'number' => $task->getNumber(),
'title' => $task->getTitle(), 'title' => $task->getTitle(),
'status' => Serializer::status($task->getStatus()), 'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'effort' => Serializer::effort($task->getEffort()), 'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()), 'effort' => Serializer::effort($task->getEffort()),
'project' => Serializer::projectRef($task->getProject()), 'group' => Serializer::groupRef($task->getGroup()),
'tags' => Serializer::tags($task->getTags()), 'project' => Serializer::projectRef($task->getProject()),
'archived' => $task->isArchived(), 'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
], array_values($tasks))); ], array_values($tasks)));
} }
} }

View File

@@ -48,6 +48,7 @@ class UpdateTaskTool
?int $assigneeId = null, ?int $assigneeId = null,
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
?array $collaboratorIds = null,
?bool $archived = null, ?bool $archived = null,
?string $scheduledStart = null, ?string $scheduledStart = null,
?string $scheduledEnd = null, ?string $scheduledEnd = null,
@@ -118,6 +119,22 @@ class UpdateTaskTool
$task->addTag($tag); $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) { if (null !== $archived) {
$task->setArchived($archived); $task->setArchived($archived);
} }
@@ -147,6 +164,7 @@ class UpdateTaskTool
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()), 'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()), 'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()), 'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()), 'tags' => Serializer::tags($task->getTags()),

View File

@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable; use Throwable;
use const ENT_HTML5;
use const ENT_QUOTES;
final class CalDavService final class CalDavService
{ {
public function __construct( public function __construct(
@@ -199,7 +202,7 @@ final class CalDavService
$project = $task->getProject(); $project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : ''; $projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle()); $summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task"; $description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar(); $vcalendar = new VCalendar();
$vcalendar->add('VEVENT', [ $vcalendar->add('VEVENT', [
@@ -225,7 +228,7 @@ final class CalDavService
$project = $task->getProject(); $project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : ''; $projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle()); $summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task"; $description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar(); $vcalendar = new VCalendar();
$vcalendar->add('VTODO', [ $vcalendar->add('VTODO', [
@@ -337,6 +340,18 @@ final class CalDavService
return sprintf('%s@lesstime', bin2hex(random_bytes(16))); return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
} }
private function descriptionToPlainText(?string $value): string
{
if (null === $value || '' === $value) {
return '';
}
$stripped = strip_tags($value);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
}
/** @return array<string, string> */ /** @return array<string, string> */
private function getDayMap(): array private function getDayMap(): array
{ {