Compare commits

...

52 Commits

Author SHA1 Message Date
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
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
gitea-actions
916f4ae101 chore: bump version to v0.3.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 12:04:40 +00:00
45d389c67f docs : guide de configuration du mode maintenance en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:03:57 +02:00
gitea-actions
7f12332cf6 chore: bump version to v0.3.25
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 12:03:43 +00:00
fe30f03b9f docs : ajout maintenance mode dans la doc de deploiement
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-03 14:03:30 +02:00
gitea-actions
fc472d5dad chore: bump version to v0.3.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-03 11:56:09 +00:00
a0a2f27eac fix(infra) : extraire maintenance.html du container au deploy
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-03 13:56:02 +02:00
gitea-actions
bd7adec2f0 chore: bump version to v0.3.23
All checks were successful
Build & Push Docker Image / build (push) Successful in 19s
Auto Tag Develop / tag (push) Successful in 5s
2026-04-03 11:54:49 +00:00
9b6386c4ae fix(infra) : root nginx-proxy vers public/ pour maintenance.html
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-03 13:54:42 +02:00
gitea-actions
9da1ae7ca1 chore: bump version to v0.3.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-03 11:50:10 +00:00
bc8bed3339 feat(infra) : ajout maintenance mode dans nginx-proxy
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:49:50 +02:00
gitea-actions
3fee678bd2 chore: bump version to v0.3.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 11:10:14 +00:00
be720178c2 feat(infra) : add maintenance mode during deployments
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Nginx returns a 503 page when maintenance.on exists. The deploy script
automatically enables/disables maintenance mode around the update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:39 +02:00
gitea-actions
eec0294f3e chore: bump version to v0.3.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 49s
2026-04-03 07:39:34 +00:00
59a1c7956c fix(auth) : allow Enter key to submit login form
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-03 09:38:17 +02:00
gitea-actions
e86949a1d7 chore: bump version to v0.3.19
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-02 12:12:10 +00:00
Matthieu
7ca62bfc46 chore(infra) : remove release artefact pipeline and baremetal deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Keep only Docker-based deployment workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:11:58 +02:00
gitea-actions
b60e4ae670 chore: bump version to v0.3.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m7s
Build Release Artefact / build (push) Successful in 1m51s
2026-04-02 10:11:41 +00:00
ace52f8fc5 fix(mcp) : add mcp-sessions dir in prod Dockerfile + add time tracking rule doc
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-01 22:59:43 +02:00
1ae9535516 refactor : reorganize infra files into infra/dev and infra/prod
Consolidate Docker, Nginx, and deploy configs from 5 scattered directories
(docker/, deploy/docker/, deploy/nginx/, script/) into a single infra/ tree
with dev/ and prod/ subdirectories. Update all references in docker-compose,
Makefile, CI workflows, Dockerfiles, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:36:10 +02:00
74 changed files with 1987 additions and 496 deletions

View File

@@ -2,10 +2,11 @@
.gitea .gitea
.env.local .env.local
.env.test .env.test
docker/ infra/dev/
deploy/docker/docker-compose.prod.yml infra/prod/docker-compose.yml
deploy/docker/deploy.sh infra/prod/deploy.sh
deploy/docker/.env.example infra/prod/deploy-release.sh
infra/prod/.env.example
frontend/node_modules frontend/node_modules
frontend/.nuxt frontend/.nuxt
frontend/.output frontend/.output

View File

@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
# Base de donnees (Doctrine / PostgreSQL) # Base de donnees (Doctrine / PostgreSQL)
# =========================================================================== # ===========================================================================
# Les variables POSTGRES_* sont definies dans docker/.env.docker # Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
# et injectees automatiquement par Docker Compose. # et injectees automatiquement par Docker Compose.
# DATABASE_URL est construite a partir de ces variables. # DATABASE_URL est construite a partir de ces variables.
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
ENCRYPTION_KEY=change_me_in_env_local ENCRYPTION_KEY=change_me_in_env_local
# =========================================================================== # ===========================================================================
# Docker (docker/.env.docker) # Docker (infra/dev/.env.docker)
# #
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker # Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
# pour les valeurs par defaut. Creez docker/.env.docker.local pour # pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
# surcharger localement. # surcharger localement.
# =========================================================================== # ===========================================================================

View File

@@ -19,7 +19,7 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: | run: |
docker build \ docker build \
-f deploy/docker/Dockerfile.prod \ -f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \ -t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/lesstime:latest \ -t gitea.malio.fr/malio-dev/lesstime:latest \
. .

View File

@@ -1,65 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \
public \
src \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/lesstime-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

2
.gitignore vendored
View File

@@ -28,5 +28,5 @@
###< ide ### ###< ide ###
###> docker local ### ###> docker local ###
docker/.env.docker.local infra/dev/.env.docker.local
###< docker local ### ###< docker local ###

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
@@ -125,7 +129,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Container PHP : `php-lesstime-fpm` - Container PHP : `php-lesstime-fpm`
- Container Nginx : `nginx-lesstime` - Container Nginx : `nginx-lesstime`
- Container DB : PostgreSQL sur port **5435** (interne et externe) - Container DB : PostgreSQL sur port **5435** (interne et externe)
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - Après modif nginx : `docker restart nginx-lesstime`
## Fixtures ## Fixtures
@@ -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

@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
| `nginx-lesstime` | 8082 | Nginx reverse proxy | | `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données | | PostgreSQL | 5435 | Base de données |
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`) Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
## API ## API

View File

@@ -21,3 +21,6 @@ mcp:
store: file store: file
directory: '%kernel.project_dir%/var/mcp-sessions' directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600 ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.3.17' app.version: '0.3.34'

View File

@@ -1,14 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}

View File

@@ -1,50 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/frontend/.output/public;
index index.html;
client_max_body_size 55m;
location ^~ /api/ {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/lesstime/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ^~ /_mcp {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -109,23 +109,33 @@ export LESSTIME_IMAGE_TAG="$TAG"
echo "==> Deploying lesstime:${TAG}..." echo "==> Deploying lesstime:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..." echo "==> Pulling image..."
docker compose pull sudo docker compose pull
echo "==> Starting container..." echo "==> Starting container..."
docker compose up -d sudo docker compose up -d
echo "==> Waiting for container to be ready..." echo "==> Waiting for container to be ready..."
sleep 3 sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
echo "==> Running migrations..." echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..." echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}') echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}" echo "==> Deployed v${VERSION}"
``` ```
@@ -192,16 +202,33 @@ Creer `/etc/nginx/sites-available/lesstime.conf` :
```nginx ```nginx
server { server {
listen 80; listen 80;
listen [::]:80;
server_name project.malio-dev.fr; server_name project.malio-dev.fr;
client_max_body_size 55m; root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / { location / {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
} }
} }
``` ```
@@ -250,6 +277,8 @@ rm /tmp/lesstime.sql
├── config/jwt/ ├── config/jwt/
│ ├── private.pem │ ├── private.pem
│ └── public.pem │ └── public.pem
├── public/
│ └── maintenance.html # extrait automatiquement par deploy.sh
└── uploads/ └── uploads/
``` ```

View File

@@ -0,0 +1,153 @@
# Configuration du mode maintenance (nginx hote)
Guide pour activer le support du mode maintenance pilote par Central.
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
## Ce qui a ete fait pour Lesstime
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/lesstime
sudo ./deploy.sh
```
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
```
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 4. Verifier
- Depuis Central, activer la maintenance sur Lesstime
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
- Desactiver la maintenance depuis Central → le site revient
---
## A faire pour Inventory
Meme procedure :
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/inventory
sudo ./deploy.sh
```
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
> ```bash
> mkdir -p public
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
> ```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name inventory.malio-dev.fr;
root /var/www/inventory/public;
# Maintenance mode
if (-f /var/www/inventory/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
---
## Fonctionnement
```
Central (container)
└── touch /var/www/maintenance/lesstime/maintenance.on
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
/var/www/lesstime/maintenance.on (hote)
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
maintenance.html servie depuis /var/www/lesstime/public/
```

View File

@@ -2,7 +2,7 @@ services:
php: php:
container_name: php-${DOCKER_APP_NAME}-fpm container_name: php-${DOCKER_APP_NAME}-fpm
build: build:
context: ./docker/php context: ./infra/dev
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION} DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -21,8 +21,8 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer - ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn - ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer - ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini - ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG - ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads - uploads_data:/var/www/html/var/uploads
extra_hosts: extra_hosts:
@@ -41,7 +41,7 @@ services:
- "8082:80" - "8082:80"
volumes: volumes:
- ./:/var/www/html:ro - ./:/var/www/html:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d: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

@@ -0,0 +1,87 @@
# Règle Claude : Time Tracking automatique via Lesstime
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
---
## Time Tracking obligatoire
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
### Déclencheurs
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
### Méthode
Créer la time entry via **curl** sur l'API REST Lesstime :
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
- Body : `{"username":"admin","password":"admin"}`
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
- Body :
```json
{
"user": "/api/users/5",
"startedAt": "<ISO8601 avec timezone>",
"title": "<description courte de la tâche>",
"project": "/api/projects/<projectId>",
"tags": ["/api/task_tags/<tagId>"],
"task": "/api/tasks/<taskId>"
}
```
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
- Body : `{"stoppedAt": "<ISO8601>"}`
### Paramètres obligatoires
- **user** : TOUJOURS `/api/users/5` (Matthieu)
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
- **title** : description courte de la tâche en cours
- **project** : selon le projet (voir mapping ci-dessous)
### Tags (choisir selon le type de travail)
| Tag | ID | IRI |
|-----|----|-----|
| Backend | 3 | `/api/task_tags/3` |
| Frontend | 2 | `/api/task_tags/2` |
| IA | 7 | `/api/task_tags/7` |
| Infra | 5 | `/api/task_tags/5` |
| UI/UX | 4 | `/api/task_tags/4` |
| Maintenance | 6 | `/api/task_tags/6` |
| RDV | 1 | `/api/task_tags/1` |
| Réunion | 8 | `/api/task_tags/8` |
| Formation | 10 | `/api/task_tags/10` |
| Gestion projet | 9 | `/api/task_tags/9` |
### Mapping projets
| Projet | ID | IRI |
|--------|----|-----|
| Lesstime | 5 | `/api/projects/5` |
| Inventory | 7 | `/api/projects/7` |
| SIRH | 12 | `/api/projects/12` |
| Infrastructure | 13 | `/api/projects/13` |
| Malio UI | 11 | `/api/projects/11` |
| ERP Liot | 6 | `/api/projects/6` |
| Ferme | 8 | `/api/projects/8` |
| ADMIN | 16 | `/api/projects/16` |
| Maintenance-LIOT | 17 | `/api/projects/17` |
| Qualiopi | 14 | `/api/projects/14` |
| Vaultwarden | 18 | `/api/projects/18` |
### Règles
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
- **Toujours stopper le timer** en fin de tâche ou sur demande
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
- **Choisir les tags intelligemment** selon le type de travail effectué

View File

@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
## 4. Installer le script de deploy ## 4. Installer le script de deploy
```bash ```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime sudo chmod +x /usr/local/bin/deploy-lesstime
``` ```
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
## 7. Configurer Nginx ## 7. Configurer Nginx
```bash ```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/ sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```

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

@@ -393,7 +393,21 @@
"title": "Mon profil", "title": "Mon profil",
"changeAvatar": "Changer l'avatar", "changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar", "removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar" "cropAvatar": "Recadrer l'avatar",
"apiToken": {
"title": "Token API MCP",
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
"label": "Token",
"empty": "Aucun token généré pour le moment.",
"generate": "Générer un token",
"regenerate": "Régénérer",
"copy": "Copier",
"copied": "Token copié dans le presse-papiers.",
"copyFailed": "Impossible de copier le token.",
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
"confirmTitle": "Régénérer le token MCP ?",
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
}
}, },
"bookstack": { "bookstack": {
"settings": { "settings": {

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,24 +17,18 @@
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"
button-class="w-full" button-class="w-full"
type="submit"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleSubmit"
/> />
<p class="font-bold">v{{ version }}</p> <p class="font-bold">v{{ version }}</p>
</form> </form>

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

@@ -37,6 +37,56 @@
</div> </div>
</div> </div>
<!-- API Token MCP (interne uniquement) -->
<div
v-if="!isClientOnly"
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
>
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
<div v-if="auth.user?.apiToken">
<MalioInputPassword
:model-value="auth.user.apiToken"
:label="$t('profile.apiToken.label')"
readonly
@update:model-value="() => {}"
/>
<div class="mt-3 flex flex-wrap gap-3">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
icon-name="mdi:content-copy"
icon-position="left"
:label="$t('profile.apiToken.copy')"
@click="onCopy"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
icon-name="mdi:refresh"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="showConfirm = true"
/>
</div>
</div>
<div v-else>
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
<MalioButton
variant="primary"
button-class="w-auto px-4"
icon-name="mdi:key-plus"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.generate')"
@click="onRegenerate"
/>
</div>
</div>
<!-- Crop modal --> <!-- Crop modal -->
<AvatarCropper <AvatarCropper
v-if="selectedFile" v-if="selectedFile"
@@ -44,14 +94,45 @@
@crop="onCrop" @crop="onCrop"
@cancel="selectedFile = null" @cancel="selectedFile = null"
/> />
<!-- Confirm regenerate modal -->
<Teleport v-if="showConfirm" to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('profile.apiToken.confirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="showConfirm = false"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="onRegenerate"
/>
</div>
</div>
</div>
</Teleport>
</div> </div>
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const { t } = useI18n()
const isClientOnly = computed(() => const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN') auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
@@ -61,9 +142,12 @@ definePageMeta({
layout: false, layout: false,
}) })
const { upload, remove } = useAvatarService() const { upload, remove } = useAvatarService()
const { regenerate } = useApiTokenService()
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)
const removing = ref(false) const removing = ref(false)
const regenerating = ref(false)
const showConfirm = ref(false)
function onFileSelect(event: Event) { function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -97,4 +181,28 @@ async function onRemove() {
removing.value = false removing.value = false
} }
} }
async function onCopy() {
if (!auth.user?.apiToken) return
try {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') })
} catch {
toast.error({ message: t('profile.apiToken.copyFailed') })
}
}
async function onRegenerate() {
regenerating.value = true
try {
const newToken = await regenerate()
if (auth.user) {
auth.user.apiToken = newToken
}
showConfirm.value = false
toast.success({ message: t('profile.apiToken.regenerated') })
} finally {
regenerating.value = false
}
}
</script> </script>

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

@@ -0,0 +1,12 @@
export function useApiTokenService() {
const api = useApi()
async function regenerate(): Promise<string> {
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
toast: false,
})
return data.apiToken
}
return { regenerate }
}

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

@@ -8,6 +8,7 @@ export type UserData = {
client?: { id: number; name: string } | null client?: { id: number; name: string } | null
allowedProjects?: Project[] allowedProjects?: Project[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null
} }
export type UserWrite = { export type UserWrite = {

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

@@ -59,8 +59,9 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
RUN rm -f /etc/nginx/sites-enabled/default RUN rm -f /etc/nginx/sites-enabled/default
# Configs # Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/lesstime.conf COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
# Backend from stage 1 # Backend from stage 1
COPY --from=backend-build /app /var/www/html COPY --from=backend-build /app /var/www/html
@@ -72,7 +73,7 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
RUN echo "APP_ENV=prod" > /var/www/html/.env RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions # Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \ RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
&& chown -R www-data:www-data /var/www/html/var && chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html WORKDIR /var/www/html

View File

@@ -8,6 +8,9 @@ export LESSTIME_IMAGE_TAG="$TAG"
echo "==> Deploying lesstime:${TAG}..." echo "==> Deploying lesstime:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..." echo "==> Pulling image..."
sudo docker compose pull sudo docker compose pull
@@ -17,6 +20,10 @@ sudo docker compose up -d
echo "==> Waiting for container to be ready..." echo "==> Waiting for container to be ready..."
sleep 3 sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
echo "==> Running migrations..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
@@ -24,5 +31,8 @@ echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}') VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}" echo "==> Deployed v${VERSION}"

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,49 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance en cours</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f3f4f6;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
padding: 48px 40px;
max-width: 480px;
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
color: #1f2937;
font-size: 24px;
margin: 0 0 16px;
}
p {
color: #6b7280;
font-size: 16px;
line-height: 1.6;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#128736;</div>
<h1>Maintenance en cours</h1>
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}

View File

@@ -2,6 +2,23 @@ server {
listen 80; listen 80;
server_name _; server_name _;
# Maintenance mode
if (-f /var/www/html/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /var/www/html/public;
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
root /var/www/html/public;
internal;
}
root /var/www/html/frontend/.output/public; root /var/www/html/frontend/.output/public;
index index.html; index index.html;

View File

@@ -1,6 +1,6 @@
# Permet d'utiliser un .env.docker.local pour override # Permet d'utiliser un .env.docker.local pour override
ENV_DEFAULT = docker/.env.docker ENV_DEFAULT = infra/dev/.env.docker
ENV_LOCAL = docker/.env.docker.local ENV_LOCAL = infra/dev/.env.docker.local
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT)) ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
# Permet d'avoir les variables du fichier .env.docker.local # Permet d'avoir les variables du fichier .env.docker.local
@@ -23,13 +23,11 @@ FILES =
#======================================================================================== #========================================================================================
env-init: env-init:
@mkdir -p docker
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL) @cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
# Lance le container # Lance le container
start: env-init start: env-init
@echo "**** START CONTAINERS ****" @echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
# Éteint le container # Éteint le container

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

@@ -1,96 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./script/deploy-release.sh v0.1.0
# Requires: curl, tar, (optional) rsync
#
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
umask 002
TAG="${1:-}"
if [ -z "$TAG" ]; then
echo "Usage: $0 v0.1.0" >&2
exit 1
fi
REPO_OWNER="MALIO-DEV"
REPO_NAME="Lesstime"
GITEA_API="https://gitea.malio.fr/api/v1"
DEPLOY_DIR="/var/www/lesstime"
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
fi
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
release_json="$tmp_dir/release.json"
curl_opts=(-sS)
if [ -n "${RELEASE_TOKEN:-}" ]; then
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
fi
curl "${curl_opts[@]}" \
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
-o "$release_json"
asset_url="$(python3 - "$release_json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], 'r'))
assets = data.get("assets", [])
for a in assets:
name = a.get("name", "")
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
print(a.get("browser_download_url", ""))
break
PY
)"
if [ -z "$asset_url" ]; then
echo "Release asset not found for tag ${TAG}" >&2
exit 1
fi
archive="$tmp_dir/artefact.tar.gz"
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
tar -xzf "$archive" -C "$tmp_dir"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --no-perms --no-owner --no-group \
--exclude ".env" \
--exclude ".env.local" \
--exclude "config/jwt" \
--exclude "var" \
"$tmp_dir"/ "$DEPLOY_DIR"/
else
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
fi
# Ensure Nginx can traverse the deploy path.
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
fi
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
# Ensure var/log exists and is writable by PHP (www-data)
mkdir -p "${DEPLOY_DIR}/var/log"
chown www-data:www-data "${DEPLOY_DIR}/var/log"
chmod 775 "${DEPLOY_DIR}/var/log"
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
echo "Clearing cache..."
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
echo "Running migrations (if any)..."
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
else
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
fi

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function bin2hex;
use function random_bytes;
class RegenerateApiTokenController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$token = bin2hex(random_bytes(32));
$user->setApiToken($token);
$this->entityManager->flush();
return new JsonResponse(['apiToken' => $token]);
}
}

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

@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?DateTimeImmutable $createdAt = null; private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)] #[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
private ?string $apiToken = null; private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]

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
{ {