Compare commits

...

13 Commits

Author SHA1 Message Date
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
19 changed files with 651 additions and 37 deletions

View File

@@ -45,6 +45,7 @@ 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 \

View File

@@ -16,7 +16,7 @@ src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatu
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

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

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;
}
}

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.0
```
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

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

@@ -289,6 +289,7 @@ const isOpen = computed({
}) })
function close() { function close() {
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
isOpen.value = false isOpen.value = false
} }
@@ -390,6 +391,8 @@ function populateForm(task: Task | null) {
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 { try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId }) clientTickets.value = await clientTicketService.getAll({ project: props.projectId })

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

@@ -236,20 +236,20 @@ onMounted(() => {
<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 gap-1">
<button <button
class="rounded-lg p-2 transition-colors" 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'" :class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')" :title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'" @click="viewMode = 'kanban'"
> >
<Icon name="mdi:view-column-outline" size="20" /> <Icon name="mdi:view-column-outline" size="18" />
</button> </button>
<button <button
class="rounded-lg p-2 transition-colors" 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'" :class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')" :title="$t('myTasks.viewList')"
@click="viewMode = 'list'" @click="viewMode = 'list'"
> >
<Icon name="mdi:view-list-outline" size="20" /> <Icon name="mdi:view-list-outline" size="18" />
</button> </button>
</div> </div>
</div> </div>

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');
}
}

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

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

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

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

@@ -54,7 +54,7 @@ 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); $task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
if (null !== $description) { if (null !== $description) {
$task->setDescription($description); $task->setDescription($description);

View File

@@ -6,6 +6,7 @@ namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry; use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
@@ -28,6 +29,7 @@ class CreateTimeEntryTool
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository, private readonly TimeEntryRepository $timeEntryRepository,
private readonly ClientTicketRepository $clientTicketRepository,
) {} ) {}
public function __invoke( public function __invoke(
@@ -39,6 +41,7 @@ class CreateTimeEntryTool
?int $taskId = null, ?int $taskId = null,
?array $tagIds = null, ?array $tagIds = null,
?string $description = null, ?string $description = null,
?int $clientTicketId = null,
): string { ): string {
$user = $this->userRepository->find($userId); $user = $this->userRepository->find($userId);
if (null === $user) { if (null === $user) {
@@ -80,6 +83,13 @@ class CreateTimeEntryTool
} }
$entry->setTask($task); $entry->setTask($task);
} }
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) { if (null !== $tagIds) {
foreach ($tagIds as $tagId) { foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId); $tag = $this->taskTagRepository->find($tagId);

View File

@@ -20,6 +20,7 @@ class ListTimeEntriesTool
?int $userId = null, ?int $userId = null,
?int $projectId = null, ?int $projectId = null,
?int $taskId = null, ?int $taskId = null,
?int $clientTicketId = null,
?string $startDate = null, ?string $startDate = null,
?string $endDate = null, ?string $endDate = null,
int $limit = 100, int $limit = 100,
@@ -31,6 +32,7 @@ class ListTimeEntriesTool
->leftJoin('te.project', 'p')->addSelect('p') ->leftJoin('te.project', 'p')->addSelect('p')
->leftJoin('te.task', 't')->addSelect('t') ->leftJoin('te.task', 't')->addSelect('t')
->leftJoin('te.tags', 'tg')->addSelect('tg') ->leftJoin('te.tags', 'tg')->addSelect('tg')
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
->orderBy('te.startedAt', 'DESC') ->orderBy('te.startedAt', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
; ;
@@ -44,6 +46,9 @@ class ListTimeEntriesTool
if (null !== $taskId) { if (null !== $taskId) {
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
} }
if (null !== $clientTicketId) {
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
}
if (null !== $startDate) { if (null !== $startDate) {
$qb->andWhere('te.startedAt >= :startDate') $qb->andWhere('te.startedAt >= :startDate')
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00')) ->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry; namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
@@ -24,6 +25,7 @@ class UpdateTimeEntryTool
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
) {} ) {}
@@ -36,6 +38,7 @@ class UpdateTimeEntryTool
?int $taskId = null, ?int $taskId = null,
?array $tagIds = null, ?array $tagIds = null,
?string $description = null, ?string $description = null,
?int $clientTicketId = null,
): string { ): string {
$entry = $this->timeEntryRepository->find($id); $entry = $this->timeEntryRepository->find($id);
@@ -69,6 +72,13 @@ class UpdateTimeEntryTool
} }
$entry->setTask($task); $entry->setTask($task);
} }
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) { if (null !== $tagIds) {
foreach ($entry->getTags()->toArray() as $existingTag) { foreach ($entry->getTags()->toArray() as $existingTag) {
$entry->removeTag($existingTag); $entry->removeTag($existingTag);

View File

@@ -28,7 +28,7 @@ class TaskRepository extends ServiceEntityRepository
$conn = $this->getEntityManager()->getConnection(); $conn = $this->getEntityManager()->getConnection();
$result = $conn->fetchOne( $result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project FOR UPDATE', 'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project',
['project' => $project->getId()], ['project' => $project->getId()],
); );