Compare commits

...

46 Commits

Author SHA1 Message Date
8befb216aa Merge pull request '[#NUMERO_TICKET] TITRE TICKET' (#2) from develop into main
Reviewed-on: #2
2026-03-18 13:16:19 +00:00
Matthieu
0113c08a60 chore : bump version to v0.3.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:21 +01:00
Matthieu
c176511d97 feat(ui) : add app title with swap button in top nav bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:12 +01:00
Matthieu
64de971872 feat(ui) : improve textarea description fields with vertical resize
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:11:00 +01:00
Matthieu
3dcc5c21a2 chore : bump version to v0.3.0
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:31 +01:00
Matthieu
47768c0f02 feat(time-tracking) : redesign calendar blocks and view mode switcher
Restyle time entry blocks with title on top, project below, tags
bottom-left, duration bottom-right. Checkerboard pattern for entries
without project. Pill-style view mode switcher. Link DateFilter mode
to main view mode and remove redundant toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
Matthieu
b278b8a23a feat(ui) : improve sidebar collapse button, logo and top nav
Move sidebar collapse toggle to mid-height floating circle button,
use LOGO_CARRE.png when collapsed, make timer button circular when
collapsed, reduce app bar height to 60px max.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
gitea-actions
4074457499 chore: bump version to v0.2.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-18 10:08:03 +00:00
Matthieu
b29b4d304d fix(user) : clear allowedProjects when removing ROLE_CLIENT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Prevents sending /api/projects/undefined when saving a user after
removing client role. Also auto-clears client and projects when
ROLE_CLIENT checkbox is unchecked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:51 +01:00
Matthieu
dd9db93751 feat(project) : add delete button for empty projects with confirmation modal
Adds taskCount virtual field on Project entity, delete button in ProjectDrawer
(visible only when taskCount === 0), and a reusable ConfirmDeleteProjectModal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:41 +01:00
gitea-actions
3e2f3b3cf8 chore: bump version to v0.2.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-17 16:02:42 +00:00
Matthieu
5bf768bc02 feat(ui) : apply pastel project colors on project cards and calendar blocks
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
- Project cards (/projects): 16px radius, pastel background, no border
- Time tracking calendar blocks: pastel opaque background, project color text

Ticket: LST-29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:02:34 +01:00
Matthieu
77c7ceb064 fix(ci) : remove templates/ from release artefact after twig removal
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:38:33 +01:00
Matthieu
ac36eeba36 chore : bump version to 0.2.8
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:36:06 +01:00
gitea-actions
005b731a97 chore: bump version to v0.2.7
Some checks failed
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Failing after 1m14s
2026-03-17 14:27:30 +00:00
Matthieu
3df0b15fe7 docs : update CLAUDE.md with BookStackConfiguration and TaskBookStackLink entities
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m15s
Ticket: T-019

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8040245e45 feat(ui) : make kanban column headers sticky with scrollable content
Give kanban containers a fixed viewport height. Column headers stay fixed
while task cards scroll independently within each column.

Ticket: LST-28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5d378c1f75 refactor(frontend) : replace any types with concrete TypeScript types
Replace 9 occurrences of 'any' with proper types: HydraCollection, Task,
ClientTicketWrite, TimeEntryWrite across 7 components.

Ticket: T-023

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8544babf8c refactor(i18n) : replace hardcoded French strings with i18n keys
Replace 30+ hardcoded strings across 15 components with $t() calls.
Added keys for common actions, drawers titles, empty states, and modals.

Ticket: T-020

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
455121132d feat(frontend) : admin middleware, fix avatar upload, centralize IRI extraction, remove Nitro proxy
- Add admin middleware protecting /admin page (ROLE_ADMIN check)
- Fix useAvatarService to use useApi() with FormData detection
- Create extractIdFromIri() utility, replace manual IRI parsing
- Remove redundant Nitro devProxy (Vite proxy handles dev)

Tickets: T-014, T-015, T-017, T-021

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
fd3097cc26 chore(backend) : rate limiting, cache-control, remove twig, clean deps
- Add login_throttling on /login_check (5 attempts/min) with symfony/rate-limiter
- Add Cache-Control: public, max-age=86400 on avatar responses
- Remove symfony/twig-bundle (unused in API-only project)
- Remove unused dev deps: symfony/browser-kit, symfony/css-selector
- Rename API Platform title to "Lesstime API"

Tickets: T-010, T-016, T-022, T-024, T-025

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ff7cff1d39 fix(backend) : add validation constraints and fix concurrent numbering
- Add Assert\Choice on ClientTicket type and status with typed constants
- Add Assert\Url on GiteaConfiguration, BookStackConfiguration, TaskBookStackLink, ClientTicket
- Fix concurrent task/ticket numbering: use pg_advisory_xact_lock instead of FOR UPDATE with MAX()
- Wrap CreateTaskTool numbering in transaction
- Harmonize repository contracts: both return max number, caller adds +1

Tickets: T-004, T-008, T-011, T-012

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ed58a402b0 fix(auth) : use dedicated plainPassword field for password hashing
- Add non-persisted plainPassword field to User entity (write-only via API)
- Remove direct write access to password field
- Update UserPasswordHasherProcessor to hash from plainPassword
- Update frontend DTO and UserDrawer component

Ticket: T-009

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
2ac815d074 fix(security) : block SVG upload, enforce ROLE_CLIENT restrictions on documents
- Block SVG MIME type in TaskDocumentProcessor upload validation
- Serve existing SVG files as attachment (defense-in-depth) in download controller
- Block ROLE_CLIENT from uploading documents to tasks (only allowed via portal tickets)
- Add Doctrine extension to filter projects by allowedProjects for ROLE_CLIENT

Tickets: T-003, T-005, T-006

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
e0dfcbdbf8 fix(security) : add role checks on Gitea API resources and all MCP tools
- GiteaBranch, GiteaBranchName, GiteaPullRequest: require ROLE_USER
- All 22 MCP tools: require ROLE_USER (ROLE_ADMIN for users/clients listing)

Tickets: T-002, T-007

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5db6b1e2b0 fix(security) : replace real secrets in .env with placeholders and create .env.example
Secrets moved to .env.local (gitignored). Added .env.example for new developers.
Also added .idea/ and docker/.env.docker.local to .gitignore and removed them from tracking.

Tickets: T-001, T-013, T-018

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
gitea-actions
6e29aeb30f chore: bump version to v0.2.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-17 09:38:00 +00:00
Matthieu
cca548dfbc chore : bump version to 0.2.5 and fix MCP session directory
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Move MCP session storage from cache dir to var/mcp-sessions
so it survives cache:clear operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:04 +01:00
Matthieu
3d4b7fad12 fix(mcp) : allow unauthenticated GET on /_mcp for SSE streaming
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Build Release Artefact / build (push) Failing after 1m16s
Claude Code MCP HTTP client sends GET SSE requests without the
Authorization header, breaking the streamable HTTP transport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:15:29 +01:00
Matthieu
5ffb4bbedc chore : bump version to 0.2.3 and add Monolog logging
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m22s
Add symfony/monolog-bundle with rotating file logs in dev (7 days)
and fingers_crossed + rotating file in prod (30 days).
Deploy script now ensures var/log/ permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:52:06 +01:00
Matthieu
d2e9f9ed65 chore : bump version to 0.2.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:08 +01:00
Matthieu
c5898fbf74 feat(ui) : add create task button on my-tasks and responsive kanban columns
- Add "Créer une tâche" button on my-tasks page with mandatory project selector
- TaskModal now accepts optional projects prop for project selection in create mode
- Replace fixed-width kanban columns (w-72 shrink-0) with flexible layout (min-w-36 flex-1)
- Add min-w-0 and overflow-x-hidden on default layout to properly contain content
- Kanban now adapts to screen size from 1024px to 1920px+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:02 +01:00
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
Matthieu
0f99098291 chore : bump version to 0.2.0 and update deploy doc
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m25s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:29:41 +01:00
Matthieu
1c6f473dff feat(mcp) : add clientTicket relation to time entries
Add ManyToOne relation from TimeEntry to ClientTicket entity.
MCP tools create-time-entry, update-time-entry, and list-time-entries
now support clientTicketId parameter for linking tickets to time entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
Matthieu
c95fff530c docs(deploy) : add deployment guide and MCP connection tutorial
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
gitea-actions
fb0e6c1ea4 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-16 08:52:02 +00:00
Matthieu
6d3ecc1322 Merge branch 'feature/client-portal' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:51:48 +01:00
Matthieu
f5986090c0 feat(deploy) : add deploy script and nginx config for bare Ubuntu server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:51:29 +01:00
Matthieu
d6399c20e1 fix : fix MCP create-task tool crashing on task creation
CreateTaskTool called nonexistent findMaxNumberByProject instead of
findMaxNumberByProjectForUpdate. Also removed FOR UPDATE clause from the
query as PostgreSQL does not support it with aggregate functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:36 +01:00
Matthieu
a972d243f5 style : center and resize view toggle buttons on my-tasks page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:34 +01:00
Matthieu
56bf88f293 fix : prevent document delete button from submitting the TaskModal form
The delete button in TaskDocumentList lacked type="button", causing it to
act as a submit button inside the form, which triggered handleSubmit and
closed the modal before the confirmation dialog could appear. Also added
guards to prevent closing TaskModal while a sub-modal is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:32 +01:00
9d80e017c2 docs : complete architecture tree in README with all directories
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:27:52 +01:00
4e91507158 docs : rewrite README with full project documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:25:42 +01:00
318f14ea88 docs : update CLAUDE.md with avatar feature context and gotchas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:21:12 +01:00
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
113 changed files with 2106 additions and 988 deletions

6
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET="a64f5614357bf56aecb1d7470e431535" APP_SECRET="change_me_in_env_local"
APP_DEBUG=1 APP_DEBUG=1
DEFAULT_URI=http://localhost/ DEFAULT_URI=http://localhost/
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f JWT_PASSPHRASE=change_me_in_env_local
JWT_COOKIE_SECURE=0 JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400 JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400 JWT_COOKIE_TTL=86400
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
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"
ENCRYPTION_KEY=aaaaaaaaa ENCRYPTION_KEY=change_me_in_env_local

99
.env.example Normal file
View File

@@ -0,0 +1,99 @@
###############################################################################
# Lesstime — Fichier d'environnement de reference
#
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
# Les valeurs par defaut dans .env suffisent pour le developpement ;
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
# etre definis dans .env.local.
#
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
###############################################################################
# ===========================================================================
# App
# ===========================================================================
# Environnement Symfony : dev, test, prod
APP_ENV=dev
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
APP_SECRET="change_me_in_env_local"
# Active/desactive le mode debug (1 = oui, 0 = non)
APP_DEBUG=1
# URI par defaut de l'application (utilise pour les liens absolus)
DEFAULT_URI=http://localhost/
# ===========================================================================
# CORS (nelmio/cors-bundle)
# ===========================================================================
# Origines autorisees pour les requetes cross-origin (regex)
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
# ===========================================================================
# JWT (lexik/jwt-authentication-bundle)
# ===========================================================================
# Chemin vers la cle privee RSA pour signer les tokens JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
# Chemin vers la cle publique RSA pour verifier les tokens JWT
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
# Passphrase de la cle privee JWT — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE=change_me_in_env_local
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
JWT_COOKIE_SECURE=0
# Duree de vie du token JWT en secondes (86400 = 24h)
JWT_TOKEN_TTL=86400
# Duree de vie du cookie JWT en secondes (86400 = 24h)
JWT_COOKIE_TTL=86400
# ===========================================================================
# Base de donnees (Doctrine / PostgreSQL)
# ===========================================================================
# Les variables POSTGRES_* sont definies dans docker/.env.docker
# et injectees automatiquement par Docker Compose.
# 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"
# ===========================================================================
# Chiffrement
# ===========================================================================
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
ENCRYPTION_KEY=change_me_in_env_local
# ===========================================================================
# Docker (docker/.env.docker)
#
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
# surcharger localement.
# ===========================================================================
# DOCKER_APP_NAME=lesstime
# DOCKER_PHP_VERSION=8.4.6
# DOCKER_NODE_VERSION=24.12.0
# APP_USER=www-data
# POSTGRES_DB=lesstime
# POSTGRES_USER=root
# POSTGRES_PASSWORD=root
# POSTGRES_PORT=5435
# XDEBUG_CLIENT_HOST=host.docker.internal
# ===========================================================================
# Frontend (frontend/.env)
# ===========================================================================
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
# NUXT_PUBLIC_API_BASE=/api

View File

@@ -45,12 +45,12 @@ jobs:
set -euo pipefail set -euo pipefail
mkdir -p release mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \ tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \ bin \
config \ config \
migrations \ migrations \
public \ public \
src \ src \
templates \
vendor \ vendor \
composer.json \ composer.json \
composer.lock \ composer.lock \

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem /config/jwt/*.pem
###< lexik/jwt-authentication-bundle ### ###< lexik/jwt-authentication-bundle ###
###> ide ###
.idea/
###< ide ###
###> docker local ###
docker/.env.docker.local
###< docker local ###

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Lesstime.iml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="386cba74:19cc24e9181:-799b" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
</modules>
</component>
</project>

20
.idea/php.xml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -12,11 +12,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure ## Structure
``` ```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument) src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink)
src/ApiResource/ # Ressources API Platform (si découplées des entités) src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor) src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
src/Service/ # Services métier (NotificationService) src/Service/ # Services métier (NotificationService)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController) src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/) src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP) src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
src/Command/ # Commandes console (GenerateApiTokenCommand) src/Command/ # Commandes console (GenerateApiTokenCommand)
@@ -28,10 +28,10 @@ migrations/ # Migrations Doctrine
docs/plans/ # Plans d'implémentation docs/plans/ # Plans d'implémentation
docs/superpowers/ # Plans et specs superpowers docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4 frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket) frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
frontend/layouts/ # Layouts (default, portal) frontend/layouts/ # Layouts (default, portal)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers) frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer) frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents) frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
frontend/services/dto/ # Types TypeScript frontend/services/dto/ # Types TypeScript
@@ -80,6 +80,8 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL - PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}` - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
### Frontend ### Frontend

190
README.md
View File

@@ -1,10 +1,173 @@
# Lesstime # Lesstime
Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4. Application de gestion de projet avec suivi du temps et portail client.
## MCP Server ## Stack
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps. | Couche | Technologies |
|--------|-------------|
| **Backend** | PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM |
| **Frontend** | Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS |
| **Base de données** | PostgreSQL 16 |
| **Auth** | JWT HTTP-only cookie (lexik/jwt-authentication-bundle) |
| **Infrastructure** | Docker (PHP-FPM, Nginx, PostgreSQL) |
## Fonctionnalités
- Gestion de projets et tâches (kanban, groupes, priorités, tags, efforts)
- Suivi du temps (timer, calendrier, vue liste)
- Portail client avec tickets (bug, amélioration, autre)
- Gestion de documents (upload, prévisualisation, téléchargement)
- Profil utilisateur avec avatar (crop circulaire)
- Notifications temps réel
- Intégration Gitea (issues, repos)
- Serveur MCP pour assistants IA
- Multi-langue (i18n)
## Prérequis
- Docker & Docker Compose
- Git
## Installation
```bash
# 1. Cloner le repo
git clone <url> && cd lesstime
# 2. Démarrer les containers
make start
# 3. Installation complète (composer, migrations, fixtures, build Nuxt)
make install
```
L'application est accessible sur **http://localhost:8082**.
### Comptes de test (fixtures)
| Utilisateur | Mot de passe | Rôle | Détails |
|-------------|-------------|------|---------|
| `admin` | `admin` | ROLE_ADMIN | Administrateur |
| `alice` | `alice` | ROLE_USER | Utilisateur interne |
| `bob` | `bob` | ROLE_USER | Utilisateur interne |
| `charlie` | `charlie` | ROLE_USER | Utilisateur interne |
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
## Commandes
### Docker
```bash
make start # Démarrer les containers
make stop # Arrêter les containers
make restart # Redémarrer les containers
make shell # Shell dans le container PHP
make shell-root # Shell root dans le container PHP
```
### Développement
```bash
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
make cache-clear # Vider le cache Symfony
make logs-dev # Tail logs Symfony
```
### Base de données
```bash
make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures
make db-reset # Reset BDD + migrations + fixtures (⚠️ supprime les données)
```
### Tests & Qualité
```bash
make test # PHPUnit
make php-cs-fixer-allow-risky # Fix code style PHP (Symfony + PSR-12)
```
### Installation complète
```bash
make install # Composer + migrations + fixtures + build Nuxt
make reset # Tout supprimer et réinstaller (⚠️ supprime la BDD)
```
## Architecture
```
src/
├── Entity/ # Entités Doctrine
├── ApiResource/ # Ressources API Platform (découplées)
├── State/ # Providers et Processors API Platform
├── Controller/ # Controllers custom Symfony
├── Service/ # Services métier
├── EventListener/ # Listeners Doctrine
├── Exception/ # Exceptions custom
├── Security/ # Authenticators custom
├── Repository/ # Repositories Doctrine
├── Command/ # Commandes console
├── DataFixtures/ # Fixtures
└── Mcp/Tool/ # MCP tools par domaine
├── Project/
├── Task/
├── TaskMeta/
├── TimeEntry/
└── Reference/
frontend/
├── pages/ # Pages Nuxt (routing auto)
│ ├── portal/ # Pages portail client
│ └── projects/ # Pages projets
├── layouts/ # Layouts (default, portal)
├── components/ # Composants Vue
│ ├── ui/ # Composants génériques
│ ├── task/ # Tâches
│ ├── user/ # Utilisateur (avatar, etc.)
│ ├── project/ # Projets
│ ├── client/ # Clients
│ ├── client-ticket/ # Tickets client
│ ├── admin/ # Administration
│ ├── notification/ # Notifications
│ └── time-tracking/ # Suivi du temps
├── composables/ # Composables (useApi, useNotifications, etc.)
├── stores/ # Stores Pinia (auth, ui, timer)
├── services/ # Services API
│ └── dto/ # Types TypeScript
├── plugins/ # Plugins Nuxt
├── utils/ # Utilitaires
├── i18n/locales/ # Traductions
└── middleware/ # Middleware auth
config/ # Config Symfony
migrations/ # Migrations Doctrine
docker/ # Dockerfiles et config Nginx
```
## Docker
| Container | Port | Description |
|-----------|------|-------------|
| `php-lesstime-fpm` | 3002 (dev Nuxt) | PHP-FPM + Node 24 |
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données |
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
## API
Toutes les routes API sont préfixées `/api` (API Platform).
- Documentation auto-générée : **http://localhost:8082/api**
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
## Serveur MCP
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA d'interagir avec les données.
### Tools disponibles (22) ### Tools disponibles (22)
@@ -16,13 +179,6 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` | | TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` | | TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
### Transports
| Transport | Usage | Auth |
|-----------|-------|------|
| **STDIO** | Claude Code sur la machine locale | Aucune |
| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer <token>`) |
### Configuration locale (STDIO) ### Configuration locale (STDIO)
```json ```json
@@ -55,17 +211,19 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
### Gestion des tokens API ### Gestion des tokens API
```bash ```bash
# Générer un token pour un utilisateur
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username> docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
``` ```
### Mise en production (réseau local) ## Déploiement
1. Déployer le code sur le serveur 1. Déployer le code sur le serveur
2. `composer install --no-dev --optimize-autoloader` 2. `composer install --no-dev --optimize-autoloader`
3. `php bin/console doctrine:migrations:migrate --no-interaction` 3. `php bin/console doctrine:migrations:migrate --no-interaction`
4. `php bin/console cache:clear --env=prod` 4. `php bin/console cache:clear --env=prod`
5. `docker restart nginx-lesstime` 5. `cd frontend && npm install && npm run build:dist`
6. `php bin/console app:generate-api-token admin` — noter le token 6. `docker restart nginx-lesstime`
7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement) 7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
8. Configurer les clients MCP avec l'URL `http://<ip-serveur>:8082/_mcp` + le token
## Licence
Propriétaire — Tous droits réservés.

View File

@@ -26,12 +26,13 @@
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0", "symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*" "symfony/yaml": "8.0.*"
}, },
@@ -90,8 +91,6 @@
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94", "friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0", "phpunit/phpunit": "^13.0"
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
} }
} }

958
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,11 @@ use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle; use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle; use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [ return [
FrameworkBundle::class => ['all' => true], FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true], SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true], DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true], DoctrineMigrationsBundle::class => ['all' => true],
@@ -24,4 +23,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true], LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true], McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
]; ];

View File

@@ -1,5 +1,5 @@
api_platform: api_platform:
title: Hello API Platform title: Lesstime API
version: 1.0.0 version: 1.0.0
formats: formats:
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']

View File

@@ -19,5 +19,5 @@ mcp:
path: /_mcp path: /_mcp
session: session:
store: file store: file
directory: '%kernel.cache_dir%/mcp-sessions' directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600 ttl: 3600

View File

@@ -0,0 +1,56 @@
monolog:
channels:
- deprecation
when@dev:
monolog:
handlers:
main:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 7
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: rotating_file
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
max_files: 7

View File

@@ -22,6 +22,9 @@ security:
pattern: ^/login_check pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
login_throttling:
max_attempts: 5
interval: '1 minute'
json_login: json_login:
check_path: /login_check check_path: /login_check
username_path: username username_path: username
@@ -59,6 +62,7 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public # Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -1,6 +0,0 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.0' app.version: '0.3.1'

View File

@@ -0,0 +1,50 @@
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

@@ -1,9 +0,0 @@
DOCKER_APP_NAME=lesstime
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=lesstime
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5435
XDEBUG_CLIENT_HOST=192.168.0.124

213
docs/deploy.md Normal file
View File

@@ -0,0 +1,213 @@
# Deploiement sur serveur Ubuntu (sans Docker)
## Prerequis
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
- Acces root ou sudo sur le serveur
## 1. Preparer la BDD
```bash
sudo -u postgres createuser lesstime
sudo -u postgres createdb -O lesstime lesstime
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
```
## 2. Creer les dossiers
```bash
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
sudo chown -R www-data:www-data /var/www/lesstime
```
## 3. Configurer l'environnement
```bash
sudo nano /var/www/lesstime/.env
```
Contenu minimal :
```ini
APP_ENV=prod
```
```bash
sudo nano /var/www/lesstime/.env.local
```
Contenu :
```ini
APP_ENV=prod
APP_SECRET=<random-hex-32>
APP_DEBUG=0
DEFAULT_URI=http://project.malio-dev.fr/
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<passphrase>
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
ENCRYPTION_KEY=<random-hex-32>
```
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
## 4. Installer le script de deploy
```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime
```
Si le repo Gitea est prive, configurer un token :
```bash
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
sudo chmod 600 /etc/lesstime-release-token
```
## 5. Deployer une release
```bash
sudo /usr/local/bin/deploy-lesstime v0.2.1
```
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
## 6. Generer les cles JWT
```bash
cd /var/www/lesstime
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
```
## 7. Configurer Nginx
```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 8. Creer le premier user admin
Hasher un mot de passe :
```bash
php /var/www/lesstime/bin/console security:hash-password --env=prod
```
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
```bash
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
```
## 9. Tester
```bash
curl http://project.malio-dev.fr/api/version
curl http://project.malio-dev.fr/
```
---
# Connecter le serveur MCP a Claude Code
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
## 1. Generer un token API
Sur le serveur (ou en local via Docker) :
```bash
# Production (serveur)
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
# Dev (Docker)
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
```
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
## 2. Configurer Claude Code
### Transport HTTP (recommande pour la prod)
Creer ou modifier `.mcp.json` a la racine du projet :
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer <ton-token>"
}
}
}
}
```
### Transport STDIO (dev local via Docker)
```json
{
"mcpServers": {
"lesstime-local": {
"command": "docker",
"args": [
"exec",
"-i",
"php-lesstime-fpm",
"php",
"bin/console",
"mcp:server"
]
}
}
}
```
### Transport STDIO via SSH (prod sans endpoint HTTP)
```json
{
"mcpServers": {
"lesstime": {
"command": "ssh",
"args": [
"user@serveur",
"php",
"/var/www/lesstime/bin/console",
"mcp:server",
"--env=prod"
]
}
}
}
```
## 3. Redemarrer Claude Code
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
## 4. Verifier
Demander a Claude d'utiliser un outil MCP, par exemple :
- "Liste les projets sur Lesstime"
- "Cree une tache dans le projet LT"
## Tools disponibles
| Domaine | Tools |
|---------|-------|
| Projets | list-projects, get-project, create-project, update-project |
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
| Reference | list-users, list-clients |

View File

@@ -274,25 +274,22 @@ const availableStatusTransitions = computed(() => {
}) })
function getProjectName(iri: string): string { function getProjectName(iri: string): string {
const match = iri.match(/\/api\/projects\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return '' if (!id) return ''
const id = Number(match[1])
return projects.value.find(p => p.id === id)?.name ?? '' return projects.value.find(p => p.id === id)?.name ?? ''
} }
function getSubmitterName(iri: string | null): string { function getSubmitterName(iri: string | null): string {
if (!iri) return '-' if (!iri) return '-'
const match = iri.match(/\/api\/users\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return '' if (!id) return ''
const id = Number(match[1])
return users.value.find(u => u.id === id)?.username ?? '' return users.value.find(u => u.id === id)?.username ?? ''
} }
function getSubmitterUser(iri: string | null): UserData | undefined { function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return undefined if (!id) return undefined
const id = Number(match[1])
return users.value.find(u => u.id === id) return users.value.find(u => u.id === id)
} }

View File

@@ -73,6 +73,7 @@
v-model="editForm.description" v-model="editForm.description"
rows="5" rows="5"
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" 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"
style="resize: vertical; min-height: 140px; max-height: 500px"
/> />
</div> </div>
@@ -191,7 +192,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket' import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets' import { useClientTicketService } from '~/services/client-tickets'
@@ -243,7 +244,7 @@ const canEdit = computed(() => {
if (!sub) return false if (!sub) return false
// submittedBy can be an IRI string or an embedded object // submittedBy can be an IRI string or an embedded object
if (typeof sub === 'string') return sub === `/api/users/${userId}` if (typeof sub === 'string') return sub === `/api/users/${userId}`
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
return false return false
}) })
@@ -270,7 +271,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') { if (props.ticket.type === 'bug') {
data.url = editForm.url || null data.url = editForm.url || null
} }
await clientTicketService.update(props.ticket.id, data as any) await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
isEditing.value = false isEditing.value = false
emit('refresh') emit('refresh')
} finally { } finally {

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.code" v-model="form.code"
@@ -64,7 +64,7 @@
</div> </div>
</form> </form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4"> <div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button <button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600" class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting" :disabled="isSubmitting"
@@ -73,7 +73,21 @@
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" /> <Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }} {{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button> </button>
<button
v-if="project.taskCount === 0"
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
<Icon name="mdi:delete-outline" size="18" />
{{ $t('common.delete') }}
</button>
</div> </div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer> </AppDrawer>
</template> </template>
@@ -104,6 +118,7 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
} }
}) })
const { create, update } = useProjectService() const { create, update, remove } = useProjectService()
async function handleSubmit() { async function handleSubmit() {
touched.name = true touched.name = true
@@ -213,6 +228,19 @@ async function handleSubmit() {
} }
} }
async function handleDelete() {
if (!props.project) return
isSubmitting.value = true
try {
await remove(props.project.id)
emit('saved')
isOpen.value = false
} finally {
confirmDeleteOpen.value = false
isSubmitting.value = false
}
}
async function handleArchiveToggle() { async function handleArchiveToggle() {
if (!props.project) return if (!props.project) return
isSubmitting.value = true isSubmitting.value = true

View File

@@ -34,6 +34,7 @@
<!-- Delete button --> <!-- Delete button -->
<button <button
v-if="isAdmin" v-if="isAdmin"
type="button"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block" class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)" @click.stop="$emit('delete', doc)"
> >

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"
@@ -267,7 +267,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) { if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string' const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task ? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}` : (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) { if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop() await timerStore.stop()
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"

View File

@@ -24,7 +24,7 @@
{{ task.project.code }}-{{ task.number }} {{ task.project.code }}-{{ task.number }}
</span> </span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900"> <h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }} {{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2> </h2>
</div> </div>
<button <button
@@ -65,6 +65,20 @@
@blur="touched.title = true" @blur="touched.title = true"
/> />
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects --> <!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect <MalioSelect
@@ -142,7 +156,12 @@
<MalioInputTextArea <MalioInputTextArea
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" :size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/> />
</div> </div>
@@ -266,6 +285,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import type { Project } from '~/services/dto/project'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
task: Task | null task: Task | null
@@ -276,6 +297,7 @@ const props = defineProps<{
tags: TaskTag[] tags: TaskTag[]
groups: TaskGroup[] groups: TaskGroup[]
users: UserData[] users: UserData[]
projects?: Project[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -289,6 +311,7 @@ const isOpen = computed({
}) })
function close() { function close() {
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
isOpen.value = false isOpen.value = false
} }
@@ -317,10 +340,12 @@ const form = reactive({
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,
projectId: null as number | null,
}) })
const touched = reactive({ const touched = reactive({
title: false, title: false,
project: false,
}) })
const statusOptions = computed(() => const statusOptions = computed(() =>
@@ -339,8 +364,22 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id }))
) )
const groupOptions = computed(() => const groupOptions = computed(() => {
props.groups.map(g => ({ label: g.title, value: g.id })) let filtered = props.groups
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
) )
const canArchive = computed(() => { const canArchive = computed(() => {
@@ -384,16 +423,25 @@ function populateForm(task: Task | null) {
form.groupId = null form.groupId = null
form.tagIds = [] form.tagIds = []
form.clientTicketId = null form.clientTicketId = null
form.projectId = null
} }
touched.title = false touched.title = false
touched.project = false
} }
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task) populateForm(props.task)
try { const pid = resolvedProjectId.value
clientTickets.value = await clientTicketService.getAll({ project: props.projectId }) if (pid) {
} catch { try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = [] clientTickets.value = []
} }
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) { if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
@@ -423,6 +471,22 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id })) clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
) )
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore() const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -509,7 +573,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) { if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string' const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task ? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}` : (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) { if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop() await timerStore.stop()
} }
@@ -538,7 +602,9 @@ async function handleUnarchive() {
async function handleSubmit() { async function handleSubmit() {
touched.title = true touched.title = true
touched.project = true
if (!form.title.trim()) return if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -550,7 +616,7 @@ async function handleSubmit() {
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,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null, group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`, project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`), tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null, clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="blockEl" ref="blockEl"
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none" class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
:style="blockStyle" :style="blockStyle"
:class="{ 'opacity-40': isDragSource }" :class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)" @contextmenu.prevent="emit('contextmenu', $event, entry)"
@@ -17,38 +17,33 @@
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" /> <div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div> </div>
<div class="px-1.5 py-0.5 h-full overflow-hidden"> <div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Full display: title + project + type dot + duration --> <!-- Top: title + project -->
<template v-if="sizeLevel >= 3"> <div class="min-w-0">
<div class="flex items-center gap-1"> <div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div> <div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span> </div>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div> <!-- Spacer -->
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden"> <div class="flex-1" />
<!-- Bottom: tags left, duration right -->
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
<div v-if="entry.tags.length" class="flex items-center gap-1 overflow-hidden min-w-0">
<span <span
v-for="tag in entry.tags" v-for="tag in entry.tags"
:key="tag.id" :key="tag.id"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90" class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white"
:style="{ backgroundColor: tag.color }"
> >
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.label }} {{ tag.label }}
</span> </span>
</div> </div>
</template> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
<!-- Medium: title + duration --> <div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
<template v-else-if="sizeLevel === 2"> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div> </div>
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
</template>
<!-- Small: title only -->
<template v-else-if="sizeLevel === 1">
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
</template>
<!-- Tiny: just a colored bar, no text -->
</div> </div>
<!-- Resize handle bottom (outside block) --> <!-- Resize handle bottom (outside block) -->
@@ -116,10 +111,11 @@ const sizeLevel = computed(() => {
return 0 return 0
}) })
const hasProject = computed(() => !!props.entry.project)
const blockStyle = computed(() => { const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const bgColor = props.entry.project?.color ?? '#94a3b8'
const col = props.columnIndex ?? 0 const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1 const total = props.totalColumns ?? 1
@@ -127,13 +123,28 @@ const blockStyle = computed(() => {
const leftPercent = (col / total) * 100 const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100 const widthPercent = (1 / total) * 100
return { const base: Record<string, string> = {
top: `${topPx}px`, top: `${topPx}px`,
height: `${heightPx.value}px`, height: `${heightPx.value}px`,
backgroundColor: bgColor,
left: `calc(${leftPercent}% + ${gapPx}px)`, left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}px)`, width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
} }
if (hasProject.value) {
const hex = props.entry.project!.color.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
base.color = `rgb(${r}, ${g}, ${b})`
} else {
base.backgroundColor = '#e5e7eb'
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
base.backgroundSize = '12px 12px'
base.color = '#6b7280'
}
return base
}) })
// --- Click / Drag detection --- // --- Click / Drag detection ---

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit"> <form class="space-y-4" @submit.prevent="onSubmit">
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -117,7 +117,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
@@ -257,7 +257,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) { if (isEditing.value && props.entry) {
await update(props.entry.id, payload) await update(props.entry.id, payload)
} else { } else {
await create(payload as any) await create(payload as TimeEntryWrite)
} }
emit('saved') emit('saved')

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400"> <div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
Aucune activité pour cette période {{ $t('timeEntries.noEntries') }}
</div> </div>
<div <div
@@ -20,7 +20,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900"> <span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || 'Sans titre' }} {{ entry.title || $t('common.untitled') }}
</span> </span>
<span <span
v-for="tag in entry.tags" v-for="tag in entry.tags"
@@ -56,7 +56,7 @@
<!-- Delete action --> <!-- Delete action -->
<button <button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100" class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
title="Supprimer" :title="$t('common.delete')"
@click.stop="emit('deleteEntry', entry)" @click.stop="emit('deleteEntry', entry)"
> >
<Icon name="mdi:delete-outline" size="18" /> <Icon name="mdi:delete-outline" size="18" />

View File

@@ -1,27 +1,27 @@
<template> <template>
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white"> <div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- Day headers --> <!-- Grid body with sticky header -->
<div <div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg" <!-- Day headers (sticky inside scroll container) -->
> <div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
<div class="w-16 shrink-0 border-r border-neutral-200" /> <div class="w-16 shrink-0 border-r border-neutral-200" />
<div <div
v-for="day in days" v-for="day in days"
:key="day.dateStr" :key="'header-' + day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center" class="flex-1 border-r border-neutral-100 py-2 text-center"
> >
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'"> <div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }} {{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div> </div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div> </div>
</div>
<!-- Grid body --> <!-- Columns -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto"> <div class="relative flex">
<!-- Hour labels --> <!-- Hour labels -->
<div class="w-16 shrink-0"> <div class="w-16 shrink-0">
<div <div
@@ -99,7 +99,7 @@
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }" :style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/> />
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div> <div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] text-neutral-500"> <div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }} {{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div> </div>
@@ -134,13 +134,16 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div> <div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div> </div>
</div> </div>
</div> </div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]
startDate: Date startDate: Date
@@ -459,7 +462,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
dragState.value = { dragState.value = {
entryId: entry.id, entryId: entry.id,
entry, entry,
title: entry.title || 'Sans titre', title: entry.title || t('common.untitled'),
color: entry.project?.color ?? '#94a3b8', color: entry.project?.color ?? '#94a3b8',
durationMinutes, durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20), ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),

View File

@@ -1,5 +1,5 @@
<template> <template>
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5"> <header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
<div class="flex h-full items-center justify-between"> <div class="flex h-full items-center justify-between">
<button <button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden" class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@@ -7,6 +7,17 @@
> >
<Icon name="mdi:menu" size="24" /> <Icon name="mdi:menu" size="24" />
</button> </button>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
<button
type="button"
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
@click="toggleTitle"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
</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">
<NotificationBell /> <NotificationBell />
<div class="group relative flex gap-2 sm:gap-4"> <div class="group relative flex gap-2 sm:gap-4">
@@ -45,6 +56,13 @@ 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

@@ -2,7 +2,7 @@
<Teleport v-if="modelValue" to="body"> <Teleport v-if="modelValue" to="body">
<Transition name="modal" appear> <Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center"> <div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" /> <div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <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('taskDocuments.confirmDeleteTitle') }}</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600"> <p class="mt-3 text-sm text-neutral-600">

View File

@@ -0,0 +1,58 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<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('projects.deleteConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('projects.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
{{ $t('common.delete') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -4,19 +4,18 @@
<div class="fixed inset-0 z-50 flex items-center justify-center"> <div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" /> <div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <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">Supprimer le statut « {{ statusLabel }} »</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600"> <p class="mt-3 text-sm text-neutral-600">
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut. {{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
Choisissez les déplacer :
</p> </p>
<div class="mt-4"> <div class="mt-4">
<MalioSelect <MalioSelect
v-model="targetStatusId" v-model="targetStatusId"
:options="targetOptions" :options="targetOptions"
label="Déplacer vers" :label="$t('taskStatuses.moveTo')"
empty-option-label="Backlog (sans statut)" :empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full" min-width="w-full"
/> />
</div> </div>
@@ -27,7 +26,7 @@
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel" @click="cancel"
> >
Annuler {{ $t('common.cancel') }}
</button> </button>
<button <button
type="button" type="button"
@@ -35,7 +34,7 @@
:disabled="isProcessing" :disabled="isProcessing"
@click="confirm" @click="confirm"
> >
Supprimer {{ $t('common.delete') }}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -15,22 +15,6 @@
> >
<template #trigger> <template #trigger>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('day')"
>
{{ t('common.day') }}
</button>
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('week')"
>
{{ t('common.weekShort') }}
</button>
</div>
<div class="relative cursor-pointer"> <div class="relative cursor-pointer">
<input <input
:value="displayValue" :value="displayValue"
@@ -85,6 +69,7 @@ const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue?: Date | [Date, Date] | null modelValue?: Date | [Date, Date] | null
placeholder?: string placeholder?: string
pickerMode?: 'day' | 'week'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -92,7 +77,7 @@ const emit = defineEmits<{
}>() }>()
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null) const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = ref<'day' | 'week'>('week') const mode = computed(() => props.pickerMode ?? 'week')
const internalValue = ref<Date | Date[] | null>(null) const internalValue = ref<Date | Date[] | null>(null)
const displayValue = computed(() => { const displayValue = computed(() => {
@@ -133,13 +118,6 @@ function formatShortDate(d: Date): string {
return `${day}/${month}` return `${day}/${month}`
} }
function switchMode(newMode: 'day' | 'week') {
if (mode.value === newMode) return
mode.value = newMode
internalValue.value = null
emit('update:modelValue', null)
}
function onUpdate(value: Date | Date[] | null) { function onUpdate(value: Date | Date[] | null) {
if (!value) { if (!value) {
emit('update:modelValue', null) emit('update:modelValue', null)
@@ -163,7 +141,6 @@ function onClear() {
} }
function selectToday() { function selectToday() {
mode.value = 'day'
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
internalValue.value = today internalValue.value = today
@@ -171,7 +148,6 @@ function selectToday() {
} }
function selectThisWeek() { function selectThisWeek() {
mode.value = 'week'
const now = new Date() const now = new Date()
const day = now.getDay() const day = now.getDay()
const monday = new Date(now) const monday = new Date(now)

View File

@@ -1,11 +1,11 @@
<template> <template>
<button <button
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition" class="flex items-center justify-center gap-2 text-sm font-semibold text-white transition"
:class="[ :class="[
timerStore.isRunning timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]' ? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600', : 'bg-primary-500 hover:bg-primary-600',
collapsed ? 'px-2' : 'px-4' collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
]" ]"
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'" :title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()" @click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit"> <form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText <MalioInputText
v-model="form.username" v-model="form.username"
@@ -90,6 +90,8 @@ import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
item: UserData | null item: UserData | null
@@ -114,7 +116,7 @@ const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([]) const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [ const clientOptions = computed(() => [
{ label: 'Aucun client', value: null as number | null }, { label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })), ...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
]) ])
@@ -146,6 +148,13 @@ function onClientChange(value: number | null) {
} }
} }
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
if (props.item) { if (props.item) {
@@ -187,10 +196,12 @@ async function handleSubmit() {
username: form.username.trim(), username: form.username.trim(),
roles: form.roles, roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null, client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`), allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
} }
if (form.password) { if (form.password) {
payload.password = form.password payload.plainPassword = form.password
} }
if (isEditing.value && props.item) { if (isEditing.value && props.item) {

View File

@@ -177,13 +177,16 @@ export function useApi(): ApiClient {
) { ) {
const needsJsonBody = method === 'POST' || method === 'PUT' const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH' const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined) const headers = new Headers(options.headers as HeadersInit | undefined)
if (needsMergePatch && !headers.has('Content-Type')) { if (!isFormData) {
headers.set('Content-Type', 'application/merge-patch+json') if (needsMergePatch && !headers.has('Content-Type')) {
} else if (needsJsonBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/merge-patch+json')
headers.set('Content-Type', 'application/json') } else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
} }
return client<T>(url, { ...options, method, headers }) return client<T>(url, { ...options, method, headers })

View File

@@ -5,11 +5,13 @@ export function useAvatarService() {
const formData = new FormData() const formData = new FormData()
formData.append('file', file, 'avatar.png') formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, { return api.post<{ avatarUrl: string }>(
method: 'POST', `/users/${userId}/avatar`,
body: formData, formData as unknown as Record<string, unknown>,
credentials: 'include', {
}) toastSuccessKey: 'profile.avatarUpdated',
}
)
} }
async function remove(userId: number): Promise<void> { async function remove(userId: number): Promise<void> {

View File

@@ -22,43 +22,69 @@
"clients": { "clients": {
"created": "Client créé avec succès.", "created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.", "updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès." "deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client",
"editClient": "Modifier un client"
}, },
"projects": { "projects": {
"title": "Projets",
"created": "Projet créé avec succès.", "created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.", "updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès.", "deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.", "archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.", "unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivés", "showArchived": "Voir les projets archivés",
"hideArchived": "Masquer les projets archivés" "hideArchived": "Masquer les projets archivés",
"noProjects": "Aucun projet trouvé.",
"noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet",
"addProjectShort": "Projet",
"editProject": "Modifier un projet",
"deleteConfirmTitle": "Supprimer le projet",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
}, },
"taskStatuses": { "taskStatuses": {
"created": "Statut créé avec succès.", "created": "Statut créé avec succès.",
"updated": "Statut mis à jour avec succès.", "updated": "Statut mis à jour avec succès.",
"deleted": "Statut supprimé avec succès." "deleted": "Statut supprimé avec succès.",
"addStatus": "Ajouter un statut",
"editStatus": "Modifier un statut",
"deleteStatus": "Supprimer le statut « {label} »",
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
"moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)"
}, },
"taskEfforts": { "taskEfforts": {
"created": "Effort créé avec succès.", "created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.", "updated": "Effort mis à jour avec succès.",
"deleted": "Effort supprimé avec succès." "deleted": "Effort supprimé avec succès.",
"addEffort": "Ajouter un effort",
"editEffort": "Modifier un effort"
}, },
"taskPriorities": { "taskPriorities": {
"created": "Priorité créée avec succès.", "created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour avec succès.", "updated": "Priorité mise à jour avec succès.",
"deleted": "Priorité supprimée avec succès." "deleted": "Priorité supprimée avec succès.",
"addPriority": "Ajouter une priorité",
"editPriority": "Modifier une priorité"
}, },
"taskTags": { "taskTags": {
"created": "Tag créé avec succès.", "created": "Tag créé avec succès.",
"updated": "Tag mis à jour avec succès.", "updated": "Tag mis à jour avec succès.",
"deleted": "Tag supprimé avec succès." "deleted": "Tag supprimé avec succès.",
"addTag": "Ajouter un tag",
"editTag": "Modifier un tag"
}, },
"taskGroups": { "taskGroups": {
"created": "Groupe créé avec succès.", "created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.", "updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.", "deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé avec succès.", "archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès." "unarchived": "Groupe désarchivé avec succès.",
"addGroup": "Ajouter un groupe",
"editGroup": "Modifier un groupe"
}, },
"taskDocuments": { "taskDocuments": {
"title": "Documents", "title": "Documents",
@@ -78,17 +104,24 @@
"archived": "Ticket archivé avec succès.", "archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.", "unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket", "deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible." "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"addTask": "Ajouter un ticket",
"editTask": "Modifier un ticket"
}, },
"users": { "users": {
"created": "Utilisateur créé avec succès.", "created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.", "updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès." "deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
}, },
"timeEntries": { "timeEntries": {
"created": "Temps enregistré", "created": "Temps enregistré",
"updated": "Temps modifié", "updated": "Temps modifié",
"deleted": "Temps supprimé" "deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps"
}, },
"archive": { "archive": {
"title": "Archives", "title": "Archives",
@@ -112,7 +145,8 @@
"allEfforts": "Tous les efforts", "allEfforts": "Tous les efforts",
"allAssignees": "Tous", "allAssignees": "Tous",
"noTasks": "Aucune tâche", "noTasks": "Aucune tâche",
"backlog": "Backlog" "backlog": "Backlog",
"createTask": "Créer une tâche"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -168,7 +202,12 @@
"cancel": "Annuler", "cancel": "Annuler",
"save": "Enregistrer", "save": "Enregistrer",
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"loading": "Chargement...", "loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
"untitled": "Sans titre",
"dateFilter": "Date", "dateFilter": "Date",
"today": "Aujourd'hui", "today": "Aujourd'hui",
"thisWeek": "Cette semaine", "thisWeek": "Cette semaine",

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full', ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]" ]"
> >
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''"> <div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img <img
v-if="!sidebarIsCollapsed" v-if="!sidebarIsCollapsed"
src="/malio.png" src="/malio.png"
@@ -26,9 +26,9 @@
/> />
<img <img
v-else v-else
src="/malio.png" src="/LOGO_CARRE.png"
alt="Logo" alt="Logo"
class="h-8 w-8 object-cover object-left" class="w-[46px] h-[55px]"
/> />
<button <button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden" class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@@ -90,7 +90,7 @@
</template> </template>
<SidebarLink <SidebarLink
to="/time-tracking" to="/time-tracking"
icon="mdi:clock-outline" icon="mdi:calendar-edit-outline"
label="Suivi de temps" label="Suivi de temps"
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
@@ -108,24 +108,26 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" /> <SidebarTimer :collapsed="sidebarIsCollapsed" />
</div> </div>
<div class="flex flex-col gap-2 items-center p-4"> <div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p> <p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
<button
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="20"
/>
</button>
</div> </div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside> </aside>
<div class="h-full flex-1 flex flex-col min-h-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" /> <AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16"> <main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" /> <div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/> <slot/>
</main> </main>
@@ -148,6 +150,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
const auth = useAuthStore() const auth = useAuthStore()
@@ -211,9 +214,9 @@ async function loadRefData() {
if (refData.loaded) return if (refData.loaded) return
const api = useApi() const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([ const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'), api.get<HydraCollection<UserData>>('/users'),
api.get<any>('/projects'), api.get<HydraCollection<Project>>('/projects'),
api.get<any>('/task_tags'), api.get<HydraCollection<TaskTag>>('/task_tags'),
]) ])
refData.users = extractHydraMembers(usersData) refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData) refData.projects = extractHydraMembers(projectsData)

View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
return navigateTo('/')
}
})

View File

@@ -23,14 +23,6 @@ export default defineNuxtConfig({
devServer: { devServer: {
port: 3002, port: 3002,
}, },
nitro: {
devProxy: {
'/api': {
target: 'http://nginx',
changeOrigin: true,
},
},
},
components: [ components: [
{path: '~/components', pathPrefix: false}, {path: '~/components', pathPrefix: false},
], ],

View File

@@ -35,6 +35,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' }) useHead({ title: 'Administration' })
const tabs = [ const tabs = [

View File

@@ -471,7 +471,7 @@ const lineOptions = {
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx: any) => `${formatHours(ctx.raw)}`, label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
}, },
}, },
}, },
@@ -480,7 +480,7 @@ const lineOptions = {
beginAtZero: true, beginAtZero: true,
grid: { color: '#f3f4f6' }, grid: { color: '#f3f4f6' },
ticks: { ticks: {
callback: (value: any) => `${value}h`, callback: (value: number | string) => `${value}h`,
}, },
}, },
x: { x: {

View File

@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
} }
// Modal // Modal
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
}
function openTaskEdit(task: Task) { function openTaskEdit(task: Task) {
selectedTask.value = task selectedTask.value = task
taskModalOpen.value = true taskModalOpen.value = true
@@ -229,28 +234,37 @@ onMounted(() => {
</script> </script>
<template> <template>
<div> <div class="min-w-0">
<!-- Header + Filters --> <!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1"> <div class="flex items-center gap-2">
<button <button
class="rounded-lg p-2 transition-colors" class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" @click="openTaskCreate"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
> >
<Icon name="mdi:view-column-outline" size="20" /> <Icon name="mdi:plus" size="18" />
</button> {{ $t('myTasks.createTask') }}
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="20" />
</button> </button>
<div class="flex gap-1">
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="18" />
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="18" />
</button>
</div>
</div> </div>
</div> </div>
@@ -314,11 +328,11 @@ onMounted(() => {
<!-- Kanban View --> <!-- Kanban View -->
<div v-if="viewMode === 'kanban'"> <div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4"> <div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div <div
v-for="status in sortedStatuses" v-for="status in sortedStatuses"
:key="status.id" :key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors" class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
@@ -326,24 +340,26 @@ onMounted(() => {
@drop.prevent="onDropStatus($event, status)" @drop.prevent="onDropStatus($event, status)"
> >
<div <div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white" class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }" :style="{ backgroundColor: status.color }"
> >
{{ status.label }} ({{ tasksByStatus(status.id).length }}) {{ status.label }} ({{ tasksByStatus(status.id).length }})
</div> </div>
<div class="flex flex-col gap-3 p-3"> <div class="min-h-0 flex-1 overflow-y-auto p-3">
<TaskCard <div class="flex flex-col gap-3">
v-for="task in tasksByStatus(status.id)" <TaskCard
:key="task.id" v-for="task in tasksByStatus(status.id)"
:task="task" :key="task.id"
@click="openTaskEdit(task)" :task="task"
/> @click="openTaskEdit(task)"
<p />
v-if="tasksByStatus(status.id).length === 0" <p
class="py-4 text-center text-xs text-neutral-400" v-if="tasksByStatus(status.id).length === 0"
> class="py-4 text-center text-xs text-neutral-400"
{{ $t('myTasks.noTasks') }} >
</p> {{ $t('myTasks.noTasks') }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -446,6 +462,7 @@ onMounted(() => {
:tags="tags" :tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups" :groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users" :users="users"
:projects="projects"
@saved="onSaved" @saved="onSaved"
/> />
</div> </div>

View File

@@ -53,10 +53,8 @@ const ticketCountByProject = computed(() => {
const counts: Record<number, number> = {} const counts: Record<number, number> = {}
for (const ticket of tickets.value) { for (const ticket of tickets.value) {
if (ticket.status === 'new' || ticket.status === 'in_progress') { if (ticket.status === 'new' || ticket.status === 'in_progress') {
// Extract project ID from IRI const projectId = extractIdFromIri(ticket.project)
const match = ticket.project.match(/\/api\/projects\/(\d+)/) if (projectId) {
if (match) {
const projectId = Number(match[1])
counts[projectId] = (counts[projectId] ?? 0) + 1 counts[projectId] = (counts[projectId] ?? 0) + 1
} }
} }

View File

@@ -31,13 +31,13 @@
</div> </div>
<!-- Kanban board --> <!-- Kanban board -->
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4"> <div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div <div
v-for="col in columns" v-for="col in columns"
:key="col.status" :key="col.status"
class="min-w-0 flex-1 sm:min-w-[280px]" class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
> >
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex shrink-0 items-center gap-2">
<div class="h-2 w-2 rounded-full" :class="col.dotClass" /> <div class="h-2 w-2 rounded-full" :class="col.dotClass" />
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3> <h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500"> <span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
@@ -45,7 +45,7 @@
</span> </span>
</div> </div>
<div <div
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors" class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''" :class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
@dragover.prevent="onDragOver(col.status)" @dragover.prevent="onDragOver(col.status)"
@dragleave="onDragLeave" @dragleave="onDragLeave"

View File

@@ -41,6 +41,11 @@
v-model="form.description" v-model="form.description"
:label="$t('clientTicket.description')" :label="$t('clientTicket.description')"
:size="5" :size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="min-w-0">
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
@@ -62,11 +62,11 @@
</div> </div>
<!-- Kanban --> <!-- Kanban -->
<div class="mt-6 flex gap-4 overflow-x-auto pb-4"> <div class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div <div
v-for="status in statuses" v-for="status in statuses"
:key="status.id" :key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors" class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
@@ -74,24 +74,26 @@
@drop.prevent="onDropStatus($event, status)" @drop.prevent="onDropStatus($event, status)"
> >
<div <div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white" class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }" :style="{ backgroundColor: status.color }"
> >
{{ status.label }} ({{ tasksByStatus(status.id).length }}) {{ status.label }} ({{ tasksByStatus(status.id).length }})
</div> </div>
<div class="flex flex-col gap-3 p-3"> <div class="min-h-0 flex-1 overflow-y-auto p-3">
<TaskCard <div class="flex flex-col gap-3">
v-for="task in tasksByStatus(status.id)" <TaskCard
:key="task.id" v-for="task in tasksByStatus(status.id)"
:task="task" :key="task.id"
@click="openTaskEdit(task)" :task="task"
/> @click="openTaskEdit(task)"
<p />
v-if="tasksByStatus(status.id).length === 0" <p
class="py-4 text-center text-xs text-neutral-400" v-if="tasksByStatus(status.id).length === 0"
> class="py-4 text-center text-xs text-neutral-400"
Aucun ticket >
</p> Aucun ticket
</p>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div> <div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
<div class="flex items-center gap-2 sm:gap-3"> <div class="flex items-center gap-2 sm:gap-3">
<button <button
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3" class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
@@ -18,8 +18,8 @@
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm" class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openCreate" @click="openCreate"
> >
<span class="hidden sm:inline">+ Ajouter un projet</span> <span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
<span class="sm:hidden">+ Projet</span> <span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -29,8 +29,9 @@
<div <div
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md" class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
:class="{ 'opacity-60': project.archived }" :class="{ 'opacity-60': project.archived }"
:style="projectCardStyle(project.color)"
@click="navigateTo(`/projects/${project.id}`)" @click="navigateTo(`/projects/${project.id}`)"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -40,7 +41,7 @@
v-if="project.archived" v-if="project.archived"
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700" class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
> >
Archivé {{ $t('common.archived') }}
</span> </span>
</div> </div>
<button <button
@@ -59,7 +60,7 @@
v-if="projects.length === 0 && !isLoading" v-if="projects.length === 0 && !isLoading"
class="col-span-full py-12 text-center text-neutral-400" class="col-span-full py-12 text-center text-neutral-400"
> >
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }} {{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
</div> </div>
</div> </div>
@@ -80,6 +81,17 @@ import { useClientService } from '~/services/clients'
useHead({ title: 'Projets' }) useHead({ title: 'Projets' })
function projectCardStyle(color: string | null) {
const hex = (color || '#222783').replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
return {
borderRadius: '16px',
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
}
}
const projectService = useProjectService() const projectService = useProjectService()
const clientService = useClientService() const clientService = useClientService()

View File

@@ -17,20 +17,26 @@
{{ currentMonthLabel }} {{ currentMonthLabel }}
</h2> </h2>
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200"> <div class="flex shrink-0 items-center gap-3">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev"> <button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" /> <Icon name="mdi:chevron-left" size="20" />
</button> </button>
<button
v-for="mode in (['week', 'day', 'list'] as const)" <div class="flex items-center rounded-full bg-neutral-100 p-1">
:key="mode" <button
class="px-3 py-1 text-sm font-semibold transition" v-for="mode in (['week', 'day', 'list'] as const)"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'" :key="mode"
@click="viewMode = mode" class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
> :class="viewMode === mode
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }} ? 'bg-primary-500 text-white shadow-sm'
</button> : 'text-neutral-500 hover:text-neutral-700'"
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext"> @click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
</button>
</div>
<button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" /> <Icon name="mdi:chevron-right" size="20" />
</button> </button>
</div> </div>
@@ -71,7 +77,7 @@
/> />
</div> </div>
<DateFilter v-model="selectedDateFilter" /> <DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
</div> </div>
</div> </div>
@@ -126,6 +132,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries' import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' }) useHead({ title: 'Suivi des temps' })
@@ -308,9 +315,9 @@ async function loadReferenceData() {
const api = useApi() const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([ const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'), api.get<HydraCollection<UserData>>('/users'),
api.get<any>('/projects'), api.get<HydraCollection<Project>>('/projects'),
api.get<any>('/task_tags'), api.get<HydraCollection<TaskTag>>('/task_tags'),
]) ])
users.value = extractHydraMembers(usersData) users.value = extractHydraMembers(usersData)
@@ -333,6 +340,7 @@ onMounted(async () => {
}) })
watch(viewMode, () => { watch(viewMode, () => {
selectedDateFilter.value = null
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value) startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
loadEntries() loadEntries()
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -13,6 +13,7 @@ export type Project = {
bookstackShelfId: number | null bookstackShelfId: number | null
bookstackShelfName: string | null bookstackShelfName: string | null
archived: boolean archived: boolean
taskCount: number
} }
export type ProjectWrite = { export type ProjectWrite = {

View File

@@ -12,7 +12,7 @@ export type UserData = {
export type UserWrite = { export type UserWrite = {
username: string username: string
password?: string plainPassword?: string
roles: string[] roles: string[]
client?: string | null client?: string | null
allowedProjects?: string[] allowedProjects?: string[]

11
frontend/utils/iri.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Extract the numeric ID from an API Platform IRI string.
* Example: "/api/projects/5" → 5
*/
export function extractIdFromIri(iri: string | null | undefined): number {
if (!iri) return 0
const lastSlash = iri.lastIndexOf('/')
if (lastSlash === -1) return 0
const id = Number(iri.substring(lastSlash + 1))
return Number.isFinite(id) ? id : 0
}

View File

@@ -0,0 +1,35 @@
<?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 Version20260316124157 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('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
}
}

96
script/deploy-release.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/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

@@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branches', uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']], normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class, provider: GiteaBranchProvider::class,
security: "is_granted('ROLE_USER')",
), ),
new Post( new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches', uriTemplate: '/tasks/{taskId}/gitea/branches',
@@ -24,6 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['gitea_branch:read']], normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class, provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class, processor: GiteaBranchProcessor::class,
security: "is_granted('ROLE_USER')",
), ),
], ],
)] )]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}', uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']], normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class, provider: GiteaBranchNameProvider::class,
security: "is_granted('ROLE_USER')",
), ),
], ],
)] )]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/pull-requests', uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']], normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class, provider: GiteaPullRequestProvider::class,
security: "is_granted('ROLE_USER')",
), ),
], ],
)] )]

View File

@@ -51,9 +51,12 @@ class TaskDocumentDownloadController extends AbstractController
$mimeType = $document->getMimeType() ?? 'application/octet-stream'; $mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images and PDFs, attachment for everything else // Inline for images and PDFs, attachment for everything else
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType // SVG files are always served as attachment to prevent XSS via embedded JavaScript
? ResponseHeaderBag::DISPOSITION_INLINE $disposition = 'image/svg+xml' === $mimeType
: ResponseHeaderBag::DISPOSITION_ATTACHMENT; ? ResponseHeaderBag::DISPOSITION_ATTACHMENT
: (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT);
$response->setContentDisposition($disposition, $document->getOriginalName()); $response->setContentDisposition($disposition, $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType); $response->headers->set('Content-Type', $mimeType);

View File

@@ -91,7 +91,7 @@ class UserAvatarController extends AbstractController
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION); $extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif']; $mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream'); $response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'no-cache, must-revalidate'); $response->headers->set('Cache-Control', 'public, max-age=86400');
return $response; return $response;
} }

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private Security $security,
) {}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Project::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
// Only restrict for ROLE_CLIENT users who are NOT admins
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$allowedProjectIds = $user->getAllowedProjects()->map(
fn (Project $project) => $project->getId(),
)->toArray();
if ([] === $allowedProjectIds) {
$queryBuilder->andWhere('1 = 0');
return;
}
$queryBuilder
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
->setParameter('allowed_project_ids', $allowedProjectIds)
;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\BookStackConfigurationRepository; use App\Repository\BookStackConfigurationRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)] #[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
class BookStackConfiguration class BookStackConfiguration
@@ -16,6 +17,7 @@ class BookStackConfiguration
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null; private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]

View File

@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -54,6 +55,27 @@ use Symfony\Component\Serializer\Attribute\Groups;
)] )]
class ClientTicket class ClientTicket
{ {
public const string TYPE_BUG = 'bug';
public const string TYPE_IMPROVEMENT = 'improvement';
public const string TYPE_OTHER = 'other';
public const array TYPES = [
self::TYPE_BUG,
self::TYPE_IMPROVEMENT,
self::TYPE_OTHER,
];
public const string STATUS_NEW = 'new';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_DONE = 'done';
public const string STATUS_REJECTED = 'rejected';
public const array STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PROGRESS,
self::STATUS_DONE,
self::STATUS_REJECTED,
];
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -66,6 +88,7 @@ class ClientTicket
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::TYPES)]
private ?string $type = null; private ?string $type = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
@@ -78,10 +101,12 @@ class ClientTicket
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])] #[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\Url]
private ?string $url = null; private ?string $url = null;
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::STATUSES)]
private ?string $status = 'new'; private ?string $status = 'new';
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\GiteaConfigurationRepository; use App\Repository\GiteaConfigurationRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)] #[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
class GiteaConfiguration class GiteaConfiguration
@@ -16,6 +17,7 @@ class GiteaConfiguration
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null; private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]

View File

@@ -13,6 +13,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -87,6 +89,15 @@ class Project
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private bool $archived = false; private bool $archived = false;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -216,4 +227,10 @@ class Project
return $this; return $this;
} }
#[Groups(['project:read'])]
public function getTaskCount(): int
{
return $this->tasks->count();
}
} }

View File

@@ -82,7 +82,7 @@ class Task
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private ?TaskGroup $group = null; private ?TaskGroup $group = null;
#[ORM\ManyToOne(targetEntity: Project::class)] #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private ?Project $project = null; private ?Project $project = null;

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Repository\TaskBookStackLinkRepository; use App\Repository\TaskBookStackLinkRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)] #[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])] #[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
@@ -31,6 +32,7 @@ class TaskBookStackLink
private string $title; private string $title;
#[ORM\Column(length: 500)] #[ORM\Column(length: 500)]
#[Assert\Url]
private string $url; private string $url;
#[ORM\Column] #[ORM\Column]

View File

@@ -85,6 +85,11 @@ class TimeEntry
#[Groups(['time_entry:read', 'time_entry:write'])] #[Groups(['time_entry:read', 'time_entry:write'])]
private ?Task $task = null; private ?Task $task = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?ClientTicket $clientTicket = null;
/** @var Collection<int, TaskTag> */ /** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)] #[ORM\ManyToMany(targetEntity: TaskTag::class)]
#[ORM\JoinTable( #[ORM\JoinTable(
@@ -189,6 +194,18 @@ class TimeEntry
return $this; return $this;
} }
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
/** @return Collection<int, TaskTag> */ /** @return Collection<int, TaskTag> */
public function getTags(): Collection public function getTags(): Collection
{ {

View File

@@ -61,9 +61,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private array $roles = []; private array $roles = [];
#[ORM\Column] #[ORM\Column]
#[Groups(['user:write'])]
private ?string $password = null; private ?string $password = null;
#[Groups(['user:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $createdAt = null; private ?DateTimeImmutable $createdAt = null;
@@ -224,5 +226,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return '/api/users/'.$this->id.'/avatar'; return '/api/users/'.$this->id.'/avatar';
} }
public function eraseCredentials(): void {} public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
} }

View File

@@ -10,6 +10,8 @@ use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -19,6 +21,7 @@ class CreateProjectTool
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository, private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -28,6 +31,10 @@ class CreateProjectTool
?string $color = null, ?string $color = null,
?int $clientId = null, ?int $clientId = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = new Project(); $project = new Project();
$project->setName($name); $project->setName($name);
$project->setCode($code); $project->setCode($code);

View File

@@ -9,6 +9,8 @@ use App\Repository\ProjectRepository;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -18,10 +20,15 @@ class GetProjectTool
public function __construct( public function __construct(
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(int $id): string public function __invoke(int $id): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($id); $project = $this->projectRepository->find($id);
if (null === $project) { if (null === $project) {

View File

@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')] #[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
class ListProjectsTool class ListProjectsTool
{ {
public function __construct( public function __construct(
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(bool $archived = false): string public function __invoke(bool $archived = false): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']); $projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
return json_encode(array_map(Serializer::project(...), $projects)); return json_encode(array_map(Serializer::project(...), $projects));

View File

@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -20,6 +22,7 @@ class UpdateProjectTool
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly ClientRepository $clientRepository, private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -31,6 +34,10 @@ class UpdateProjectTool
?int $clientId = null, ?int $clientId = null,
?bool $archived = null, ?bool $archived = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($id); $project = $this->projectRepository->find($id);
if (null === $project) { if (null === $project) {

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository; use App\Repository\ClientRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')] #[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
class ListClientsTool class ListClientsTool
{ {
public function __construct( public function __construct(
private readonly ClientRepository $clientRepository, private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(): string
{ {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']); $clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($client) => [ return json_encode(array_map(fn ($client) => [

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')] #[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
class ListUsersTool class ListUsersTool
{ {
public function __construct( public function __construct(
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(): string
{ {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$users = $this->userRepository->findBy([], ['username' => 'ASC']); $users = $this->userRepository->findBy([], ['username' => 'ASC']);
return json_encode(array_map(fn ($user) => [ return json_encode(array_map(fn ($user) => [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool; namespace App\Mcp\Tool;
use App\Entity\ClientTicket;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Task; use App\Entity\Task;
use App\Entity\TaskDocument; use App\Entity\TaskDocument;
@@ -239,22 +240,39 @@ final class Serializer
]; ];
} }
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function clientTicketRef(?ClientTicket $ticket): ?array
{
if (null === $ticket) {
return null;
}
return [
'id' => $ticket->getId(),
'number' => $ticket->getNumber(),
'title' => $ticket->getTitle(),
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public static function timeEntry(TimeEntry $entry): array public static function timeEntry(TimeEntry $entry): array
{ {
return [ return [
'id' => $entry->getId(), 'id' => $entry->getId(),
'title' => $entry->getTitle(), 'title' => $entry->getTitle(),
'description' => $entry->getDescription(), 'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'), 'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'), 'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry), 'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()), 'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null, 'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()), 'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()), 'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
'tags' => self::tags($entry->getTags()),
]; ];
} }

View File

@@ -17,6 +17,8 @@ use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -33,6 +35,7 @@ class CreateTaskTool
private readonly TaskGroupRepository $taskGroupRepository, private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -46,6 +49,10 @@ class CreateTaskTool
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($projectId); $project = $this->projectRepository->find($projectId);
if (null === $project) { if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
@@ -54,7 +61,6 @@ class CreateTaskTool
$task = new Task(); $task = new Task();
$task->setProject($project); $task->setProject($project);
$task->setTitle($title); $task->setTitle($title);
$task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1);
if (null !== $description) { if (null !== $description) {
$task->setDescription($description); $task->setDescription($description);
@@ -104,8 +110,11 @@ class CreateTaskTool
} }
} }
$this->entityManager->persist($task); $this->entityManager->wrapInTransaction(function () use ($task, $project): void {
$this->entityManager->flush(); $task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
$this->entityManager->persist($task);
$this->entityManager->flush();
});
return json_encode([ return json_encode([
'id' => $task->getId(), 'id' => $task->getId(),

View File

@@ -8,6 +8,8 @@ use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -17,10 +19,15 @@ class DeleteTaskTool
public function __construct( public function __construct(
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {} ) {}
public function __invoke(int $id): string public function __invoke(int $id): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id); $task = $this->taskRepository->find($id);
if (null === $task) { if (null === $task) {

View File

@@ -8,6 +8,8 @@ use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -16,10 +18,15 @@ class GetTaskTool
{ {
public function __construct( public function __construct(
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(int $id): string public function __invoke(int $id): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id); $task = $this->taskRepository->find($id);
if (null === $task) { if (null === $task) {

View File

@@ -7,12 +7,15 @@ namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')] #[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, 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(
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -25,6 +28,10 @@ class ListTasksTool
bool $archived = false, bool $archived = false,
int $limit = 100, int $limit = 100,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$limit = min($limit, 200); $limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t') $qb = $this->taskRepository->createQueryBuilder('t')

View File

@@ -15,6 +15,8 @@ use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -30,6 +32,7 @@ class UpdateTaskTool
private readonly TaskGroupRepository $taskGroupRepository, private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -44,6 +47,10 @@ class UpdateTaskTool
?array $tagIds = null, ?array $tagIds = null,
?bool $archived = null, ?bool $archived = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id); $task = $this->taskRepository->find($id);
if (null === $task) { if (null === $task) {

View File

@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
@@ -19,6 +21,7 @@ class CreateGroupTool
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly Security $security,
) {} ) {}
public function __invoke( public function __invoke(
@@ -27,6 +30,10 @@ class CreateGroupTool
?string $description = null, ?string $description = null,
?string $color = null, ?string $color = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($projectId); $project = $this->projectRepository->find($projectId);
if (null === $project) { if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository; use App\Repository\TaskEffortRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')] #[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
class ListEffortsTool class ListEffortsTool
{ {
public function __construct( public function __construct(
private readonly TaskEffortRepository $taskEffortRepository, private readonly TaskEffortRepository $taskEffortRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']); $efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($e) => [ return json_encode(array_map(fn ($e) => [

View File

@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository; use App\Repository\TaskGroupRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')] #[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
class ListGroupsTool class ListGroupsTool
{ {
public function __construct( public function __construct(
private readonly TaskGroupRepository $taskGroupRepository, private readonly TaskGroupRepository $taskGroupRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(?int $projectId = null, bool $archived = false): string public function __invoke(?int $projectId = null, bool $archived = false): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$criteria = ['archived' => $archived]; $criteria = ['archived' => $archived];
if (null !== $projectId) { if (null !== $projectId) {
$criteria['project'] = $projectId; $criteria['project'] = $projectId;

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository; use App\Repository\TaskPriorityRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')] #[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
class ListPrioritiesTool class ListPrioritiesTool
{ {
public function __construct( public function __construct(
private readonly TaskPriorityRepository $taskPriorityRepository, private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']); $priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($p) => [ return json_encode(array_map(fn ($p) => [

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository; use App\Repository\TaskStatusRepository;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')] #[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
class ListStatusesTool class ListStatusesTool
{ {
public function __construct( public function __construct(
private readonly TaskStatusRepository $taskStatusRepository, private readonly TaskStatusRepository $taskStatusRepository,
private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(): string
{ {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']); $statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($s) => [ return json_encode(array_map(fn ($s) => [

Some files were not shown because too many files have changed in this diff Show More