Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e | ||
|
|
258c6e9c17 | ||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
|
|
f1fd80d9ac | ||
|
|
24e3e8e989 | ||
|
|
47f2ab9cd4 | ||
|
|
36729f8f61 | ||
|
|
30b090852d | ||
|
|
f0c9568521 | ||
|
|
7c37eb58cb | ||
|
|
7a5b8dabff | ||
|
|
fef563be06 | ||
|
|
e14c707dfd | ||
|
|
fa7bb27ef5 | ||
|
|
21e9d2cab4 | ||
|
|
00ffcb1cf2 | ||
|
|
daba09472f | ||
|
|
f3208a481f | ||
|
|
a46542fcdd | ||
|
|
1ae2d9ac2c | ||
|
|
e41caa9cfe | ||
|
|
916f4ae101 | ||
| 45d389c67f | |||
|
|
7f12332cf6 | ||
| fe30f03b9f | |||
|
|
fc472d5dad | ||
| a0a2f27eac | |||
|
|
bd7adec2f0 | ||
| 9b6386c4ae | |||
|
|
9da1ae7ca1 | ||
| bc8bed3339 | |||
|
|
3fee678bd2 | ||
| be720178c2 | |||
|
|
eec0294f3e | ||
| 59a1c7956c | |||
|
|
e86949a1d7 | ||
|
|
7ca62bfc46 | ||
|
|
b60e4ae670 | ||
| ace52f8fc5 | |||
| 1ae9535516 | |||
|
|
b50cfb5049 | ||
|
|
a5227b9936 | ||
|
|
0d298db797 | ||
|
|
cbe71a1f32 | ||
|
|
a8fa8fd7e0 | ||
|
|
4aa2abd396 |
@@ -2,10 +2,11 @@
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
infra/dev/
|
||||
infra/prod/docker-compose.yml
|
||||
infra/prod/deploy.sh
|
||||
infra/prod/deploy-release.sh
|
||||
infra/prod/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
|
||||
@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
|
||||
# Base de donnees (Doctrine / PostgreSQL)
|
||||
# ===========================================================================
|
||||
|
||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||
# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
|
||||
# et injectees automatiquement par Docker Compose.
|
||||
# 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"
|
||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
|
||||
# ===========================================================================
|
||||
# Docker (docker/.env.docker)
|
||||
# Docker (infra/dev/.env.docker)
|
||||
#
|
||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||
# surcharger localement.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-t gitea.malio.fr/malio/lesstime:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio/lesstime:latest \
|
||||
-f infra/prod/Dockerfile \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||
.
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push gitea.malio.fr/malio/lesstime:${{ gitea.ref_name }}
|
||||
docker push gitea.malio.fr/malio/lesstime:latest
|
||||
docker push gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }}
|
||||
docker push gitea.malio.fr/malio-dev/lesstime:latest
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/lesstime-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,5 +28,5 @@
|
||||
###< ide ###
|
||||
|
||||
###> docker local ###
|
||||
docker/.env.docker.local
|
||||
infra/dev/.env.docker.local
|
||||
###< docker local ###
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||
|
||||
### Composants UI
|
||||
|
||||
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||
|
||||
### MCP Server
|
||||
|
||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||
@@ -125,7 +129,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Container PHP : `php-lesstime-fpm`
|
||||
- Container Nginx : `nginx-lesstime`
|
||||
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
||||
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
- Après modif nginx : `docker restart nginx-lesstime`
|
||||
|
||||
## Fixtures
|
||||
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
|
||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||
| PostgreSQL | 5435 | Base de données |
|
||||
|
||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -21,3 +21,6 @@ mcp:
|
||||
store: file
|
||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||
ttl: 3600
|
||||
discovery:
|
||||
scan_dirs: ['src']
|
||||
exclude_dirs: ['DataFixtures']
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.14'
|
||||
app.version: '0.3.34'
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,14 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ Creer `docker-compose.yml` :
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||
container_name: lesstime-app
|
||||
env_file: .env
|
||||
ports:
|
||||
@@ -109,23 +109,33 @@ export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
@@ -192,16 +202,33 @@ Creer `/etc/nginx/sites-available/lesstime.conf` :
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
client_max_body_size 55m;
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -250,6 +277,8 @@ rm /tmp/lesstime.sql
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
├── public/
|
||||
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
@@ -292,7 +321,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:migrations:migra
|
||||
|
||||
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||
1. Build l'image multi-stage
|
||||
2. Push vers `gitea.malio.fr/malio/lesstime:<tag>` et `:latest`
|
||||
2. Push vers `gitea.malio.fr/malio-dev/lesstime:<tag>` et `:latest`
|
||||
|
||||
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||
|
||||
|
||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Configuration du mode maintenance (nginx hote)
|
||||
|
||||
Guide pour activer le support du mode maintenance pilote par Central.
|
||||
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||
|
||||
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||
|
||||
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||
|
||||
## Ce qui a ete fait pour Lesstime
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||
```
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Verifier
|
||||
|
||||
- Depuis Central, activer la maintenance sur Lesstime
|
||||
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||
- Desactiver la maintenance depuis Central → le site revient
|
||||
|
||||
---
|
||||
|
||||
## A faire pour Inventory
|
||||
|
||||
Meme procedure :
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/inventory
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||
> ```bash
|
||||
> mkdir -p public
|
||||
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
> ```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
root /var/www/inventory/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/inventory/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
```
|
||||
Central (container)
|
||||
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||
▼
|
||||
/var/www/lesstime/maintenance.on (hote)
|
||||
│
|
||||
▼
|
||||
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||
│
|
||||
▼
|
||||
maintenance.html servie depuis /var/www/lesstime/public/
|
||||
```
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
php:
|
||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||
build:
|
||||
context: ./docker/php
|
||||
context: ./infra/dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./:/var/www/html:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
87
docs/claude-time-tracking-rule.md
Normal file
87
docs/claude-time-tracking-rule.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Règle Claude : Time Tracking automatique via Lesstime
|
||||
|
||||
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
|
||||
|
||||
---
|
||||
|
||||
## Time Tracking obligatoire
|
||||
|
||||
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
|
||||
|
||||
### Déclencheurs
|
||||
|
||||
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
|
||||
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
|
||||
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
|
||||
|
||||
### Méthode
|
||||
|
||||
Créer la time entry via **curl** sur l'API REST Lesstime :
|
||||
|
||||
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
|
||||
- Body : `{"username":"admin","password":"admin"}`
|
||||
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
|
||||
|
||||
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
|
||||
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
|
||||
- Body :
|
||||
```json
|
||||
{
|
||||
"user": "/api/users/5",
|
||||
"startedAt": "<ISO8601 avec timezone>",
|
||||
"title": "<description courte de la tâche>",
|
||||
"project": "/api/projects/<projectId>",
|
||||
"tags": ["/api/task_tags/<tagId>"],
|
||||
"task": "/api/tasks/<taskId>"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
|
||||
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
|
||||
- Body : `{"stoppedAt": "<ISO8601>"}`
|
||||
|
||||
### Paramètres obligatoires
|
||||
|
||||
- **user** : TOUJOURS `/api/users/5` (Matthieu)
|
||||
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
|
||||
- **title** : description courte de la tâche en cours
|
||||
- **project** : selon le projet (voir mapping ci-dessous)
|
||||
|
||||
### Tags (choisir selon le type de travail)
|
||||
|
||||
| Tag | ID | IRI |
|
||||
|-----|----|-----|
|
||||
| Backend | 3 | `/api/task_tags/3` |
|
||||
| Frontend | 2 | `/api/task_tags/2` |
|
||||
| IA | 7 | `/api/task_tags/7` |
|
||||
| Infra | 5 | `/api/task_tags/5` |
|
||||
| UI/UX | 4 | `/api/task_tags/4` |
|
||||
| Maintenance | 6 | `/api/task_tags/6` |
|
||||
| RDV | 1 | `/api/task_tags/1` |
|
||||
| Réunion | 8 | `/api/task_tags/8` |
|
||||
| Formation | 10 | `/api/task_tags/10` |
|
||||
| Gestion projet | 9 | `/api/task_tags/9` |
|
||||
|
||||
### Mapping projets
|
||||
|
||||
| Projet | ID | IRI |
|
||||
|--------|----|-----|
|
||||
| Lesstime | 5 | `/api/projects/5` |
|
||||
| Inventory | 7 | `/api/projects/7` |
|
||||
| SIRH | 12 | `/api/projects/12` |
|
||||
| Infrastructure | 13 | `/api/projects/13` |
|
||||
| Malio UI | 11 | `/api/projects/11` |
|
||||
| ERP Liot | 6 | `/api/projects/6` |
|
||||
| Ferme | 8 | `/api/projects/8` |
|
||||
| ADMIN | 16 | `/api/projects/16` |
|
||||
| Maintenance-LIOT | 17 | `/api/projects/17` |
|
||||
| Qualiopi | 14 | `/api/projects/14` |
|
||||
| Vaultwarden | 18 | `/api/projects/18` |
|
||||
|
||||
### Règles
|
||||
|
||||
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
|
||||
- **Toujours stopper le timer** en fin de tâche ou sur demande
|
||||
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
|
||||
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
|
||||
- **Choisir les tags intelligemment** selon le type de travail effectué
|
||||
@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
|
||||
## 4. Installer le script de deploy
|
||||
|
||||
```bash
|
||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
||||
## 7. Configurer Nginx
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
||||
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
|
||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
@@ -10,21 +10,17 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
|
||||
@@ -66,14 +66,10 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
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"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,7 +125,13 @@
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
group-class="mt-1"
|
||||
/>
|
||||
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||
</div>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
|
||||
@@ -116,7 +116,12 @@
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ item.description ?? '—' }}
|
||||
{{ stripRichText(item.description) || '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButton
|
||||
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
|
||||
@@ -78,11 +78,17 @@
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="ml-auto h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
class="ml-auto"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
min-height="120px"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
|
||||
@@ -86,17 +86,25 @@
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,15 +170,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collaborators -->
|
||||
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="user in collaboratorOptions"
|
||||
:key="user.value"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.collaboratorIds.includes(user.value)
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="user.value"
|
||||
:checked="form.collaboratorIds.includes(user.value)"
|
||||
@change="toggleCollaborator(user.value)"
|
||||
/>
|
||||
{{ user.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-5">
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -544,6 +565,7 @@ const form = reactive({
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
@@ -586,6 +608,18 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const collaboratorOptions = computed(() =>
|
||||
props.users
|
||||
.filter(u => u.id !== form.assigneeId)
|
||||
.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||
if (newAssigneeId) {
|
||||
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||
}
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let filtered = props.groups.filter(g => !g.archived)
|
||||
if (showProjectSelect.value && form.projectId) {
|
||||
@@ -624,6 +658,12 @@ function toggleTag(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollaborator(userId: number) {
|
||||
const idx = form.collaboratorIds.indexOf(userId)
|
||||
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||
else form.collaboratorIds.push(userId)
|
||||
}
|
||||
|
||||
const weekDays = computed(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
@@ -648,6 +688,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
@@ -694,6 +735,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
@@ -906,6 +948,7 @@ async function handleSubmit() {
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
|
||||
@@ -10,15 +10,7 @@
|
||||
@click="ui.openMobileSidebar()"
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
||||
@click="toggleTitle"
|
||||
/>
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
@@ -66,13 +58,6 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
||||
|
||||
function toggleTitle() {
|
||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
||||
localStorage.setItem('appTitle', appTitle.value)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="md-preview" appear>
|
||||
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(80vh, 700px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-slate-800">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="overflow-y-auto px-6 py-4">
|
||||
<div
|
||||
v-if="content"
|
||||
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-slate-400">
|
||||
Aucune description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
content: string
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return marked.parse(props.content, { async: false }) as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-preview-enter-active,
|
||||
.md-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.md-preview-enter-from,
|
||||
.md-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,12 +8,11 @@
|
||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.username = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
label="Mot de passe"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||
@blur="touched.password = true"
|
||||
/>
|
||||
|
||||
@@ -393,7 +393,21 @@
|
||||
"title": "Mon profil",
|
||||
"changeAvatar": "Changer l'avatar",
|
||||
"removeAvatar": "Supprimer l'avatar",
|
||||
"cropAvatar": "Recadrer l'avatar"
|
||||
"cropAvatar": "Recadrer l'avatar",
|
||||
"apiToken": {
|
||||
"title": "Token API MCP",
|
||||
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
|
||||
"label": "Token",
|
||||
"empty": "Aucun token généré pour le moment.",
|
||||
"generate": "Générer un token",
|
||||
"regenerate": "Régénérer",
|
||||
"copy": "Copier",
|
||||
"copied": "Token copié dans le presse-papiers.",
|
||||
"copyFailed": "Impossible de copier le token.",
|
||||
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
|
||||
"confirmTitle": "Régénérer le token MCP ?",
|
||||
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
|
||||
}
|
||||
},
|
||||
"bookstack": {
|
||||
"settings": {
|
||||
|
||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['markdown-it-task-lists'],
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
settings: {
|
||||
|
||||
921
frontend/package-lock.json
generated
921
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,15 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||
)
|
||||
|
||||
// ── Filtered data (client-side project filter) ──
|
||||
// ── Filtered data (client-side project + user filter) ──
|
||||
|
||||
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||
|
||||
const tasks = computed(() => {
|
||||
if (!selectedProjectId.value) return allTasks.value
|
||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
||||
let result = allTasks.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedUserId.value) {
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedUserId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const timeEntries = computed(() => {
|
||||
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
|
||||
)
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
||||
tasks.value.filter(t =>
|
||||
t.assignee?.id === effectiveUserId.value
|
||||
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||
)
|
||||
)
|
||||
|
||||
const myTasksDone = computed(() =>
|
||||
|
||||
@@ -17,24 +17,18 @@
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
|
||||
@@ -51,8 +51,9 @@ const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||
const sortBy = ref<SortOption>('default')
|
||||
const SORT_DEADLINE = 1
|
||||
const SORT_SCHEDULED = 2
|
||||
const sortById = ref<number | null>(null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
@@ -106,6 +107,11 @@ const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||
])
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
@@ -140,33 +146,43 @@ async function loadReferenceData() {
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortBy.value === 'deadline') {
|
||||
params['order[deadline]'] = 'asc'
|
||||
} else if (sortBy.value === 'scheduledStart') {
|
||||
params['order[scheduledStart]'] = 'asc'
|
||||
if (sortById.value === SORT_DEADLINE) {
|
||||
baseParams['order[deadline]'] = 'asc'
|
||||
} else if (sortById.value === SORT_SCHEDULED) {
|
||||
baseParams['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
|
||||
if (selectedAssigneeId.value) {
|
||||
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||
])
|
||||
const map = new Map<number, Task>()
|
||||
for (const t of assigneeTasks) map.set(t.id, t)
|
||||
for (const t of collabTasks) map.set(t.id, t)
|
||||
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||
} else {
|
||||
tasks.value = await taskService.getFiltered(baseParams)
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -180,7 +196,7 @@ async function loadAll() {
|
||||
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
@@ -400,17 +416,15 @@ onMounted(async () => {
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,15 +37,10 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
v-if="!isClientOnly"
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||
|
||||
<div v-if="auth.user?.apiToken">
|
||||
<MalioInputPassword
|
||||
:model-value="auth.user.apiToken"
|
||||
:label="$t('profile.apiToken.label')"
|
||||
readonly
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:content-copy"
|
||||
icon-position="left"
|
||||
:label="$t('profile.apiToken.copy')"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:refresh"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="showConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:key-plus"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.generate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crop modal -->
|
||||
<AvatarCropper
|
||||
v-if="selectedFile"
|
||||
@@ -44,14 +94,45 @@
|
||||
@crop="onCrop"
|
||||
@cancel="selectedFile = null"
|
||||
/>
|
||||
|
||||
<!-- Confirm regenerate modal -->
|
||||
<Teleport v-if="showConfirm" to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('profile.apiToken.confirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('common.cancel')"
|
||||
@click="showConfirm = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
import { useApiTokenService } from '~/services/api-token'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
@@ -61,9 +142,12 @@ definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -97,4 +181,28 @@ async function onRemove() {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
if (!auth.user?.apiToken) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(auth.user.apiToken)
|
||||
toast.success({ message: t('profile.apiToken.copied') })
|
||||
} catch {
|
||||
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate() {
|
||||
regenerating.value = true
|
||||
try {
|
||||
const newToken = await regenerate()
|
||||
if (auth.user) {
|
||||
auth.user.apiToken = newToken
|
||||
}
|
||||
showConfirm.value = false
|
||||
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -84,7 +84,12 @@
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
@@ -258,6 +259,12 @@ const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'kanban') {
|
||||
selectedStatusId.value = null
|
||||
}
|
||||
})
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
@@ -298,7 +305,10 @@ const filteredTasks = computed(() => {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedAssigneeId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||
)
|
||||
}
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
let result = entries.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||
}
|
||||
return result
|
||||
})
|
||||
const filteredEntries = computed(() => entries.value)
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() - 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() - 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() + 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() + 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
if (viewMode.value === 'day') {
|
||||
end.setDate(end.getDate() + 1)
|
||||
} else if (viewMode.value === 'list') {
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
} else {
|
||||
end.setDate(end.getDate() + 7)
|
||||
}
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
project: selectedProjectId.value ?? undefined,
|
||||
tag: selectedTagId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
// keep current date
|
||||
} else if (viewMode.value === 'list') {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
startDate.value = getMonday(startDate.value)
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedUserId, () => {
|
||||
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
|
||||
12
frontend/services/api-token.ts
Normal file
12
frontend/services/api-token.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function useApiTokenService() {
|
||||
const api = useApi()
|
||||
|
||||
async function regenerate(): Promise<string> {
|
||||
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
|
||||
toast: false,
|
||||
})
|
||||
return data.apiToken
|
||||
}
|
||||
|
||||
return { regenerate }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type Task = {
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
collaborators: UserData[]
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
@@ -55,6 +56,7 @@ export type TaskWrite = {
|
||||
effort: string | null
|
||||
priority: string | null
|
||||
assignee: string | null
|
||||
collaborators?: string[]
|
||||
group: string | null
|
||||
project: string
|
||||
tags: string[]
|
||||
|
||||
@@ -8,6 +8,7 @@ export type UserData = {
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
|
||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
||||
after: string
|
||||
before: string
|
||||
user?: number
|
||||
types?: number[]
|
||||
project?: number
|
||||
tag?: number
|
||||
}): Promise<TimeEntry[]> {
|
||||
const query: Record<string, unknown> = {
|
||||
'startedAt[after]': params.after,
|
||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
||||
if (params.user) {
|
||||
query.user = `/api/users/${params.user}`
|
||||
}
|
||||
if (params.project) {
|
||||
query.project = `/api/projects/${params.project}`
|
||||
}
|
||||
if (params.tag) {
|
||||
query['tags[]'] = `/api/task_tags/${params.tag}`
|
||||
}
|
||||
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
plugins: [typography],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
export function stripRichText(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, '\'')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
@@ -60,8 +59,9 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
@@ -73,7 +73,7 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
38
infra/prod/deploy.sh
Executable file
38
infra/prod/deploy.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,13 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||
container_name: lesstime-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
- lesstime_logs:/var/www/html/var/log
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
lesstime_logs:
|
||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
31
infra/prod/nginx-proxy.conf
Normal file
31
infra/prod/nginx-proxy.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,23 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/html/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
root /var/www/html/public;
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/www/html/public;
|
||||
internal;
|
||||
}
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
6
makefile
6
makefile
@@ -1,6 +1,6 @@
|
||||
# Permet d'utiliser un .env.docker.local pour override
|
||||
ENV_DEFAULT = docker/.env.docker
|
||||
ENV_LOCAL = docker/.env.docker.local
|
||||
ENV_DEFAULT = infra/dev/.env.docker
|
||||
ENV_LOCAL = infra/dev/.env.docker.local
|
||||
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
||||
|
||||
# Permet d'avoir les variables du fichier .env.docker.local
|
||||
@@ -23,13 +23,11 @@ FILES =
|
||||
#========================================================================================
|
||||
|
||||
env-init:
|
||||
@mkdir -p docker
|
||||
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
||||
|
||||
# Lance le container
|
||||
start: env-init
|
||||
@echo "**** START CONTAINERS ****"
|
||||
@cp --update=none docker/.env.docker docker/.env.docker.local
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
|
||||
# Éteint le container
|
||||
|
||||
37
migrations/Version20260409075411.php
Normal file
37
migrations/Version20260409075411.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260409075411 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE task_collaborator (task_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (task_id, user_id))');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C518DB60186 ON task_collaborator (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C51A76ED395 ON task_collaborator (user_id)');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C518DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C51A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C518DB60186');
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C51A76ED395');
|
||||
$this->addSql('DROP TABLE task_collaborator');
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./script/deploy-release.sh v0.1.0
|
||||
# Requires: curl, tar, (optional) rsync
|
||||
#
|
||||
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
|
||||
umask 002
|
||||
|
||||
TAG="${1:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Usage: $0 v0.1.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_OWNER="MALIO-DEV"
|
||||
REPO_NAME="Lesstime"
|
||||
GITEA_API="https://gitea.malio.fr/api/v1"
|
||||
DEPLOY_DIR="/var/www/lesstime"
|
||||
|
||||
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
release_json="$tmp_dir/release.json"
|
||||
curl_opts=(-sS)
|
||||
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||
fi
|
||||
curl "${curl_opts[@]}" \
|
||||
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
||||
-o "$release_json"
|
||||
|
||||
asset_url="$(python3 - "$release_json" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], 'r'))
|
||||
assets = data.get("assets", [])
|
||||
for a in assets:
|
||||
name = a.get("name", "")
|
||||
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
|
||||
print(a.get("browser_download_url", ""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
|
||||
if [ -z "$asset_url" ]; then
|
||||
echo "Release asset not found for tag ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive="$tmp_dir/artefact.tar.gz"
|
||||
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
||||
|
||||
tar -xzf "$archive" -C "$tmp_dir"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete --no-perms --no-owner --no-group \
|
||||
--exclude ".env" \
|
||||
--exclude ".env.local" \
|
||||
--exclude "config/jwt" \
|
||||
--exclude "var" \
|
||||
"$tmp_dir"/ "$DEPLOY_DIR"/
|
||||
else
|
||||
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
||||
fi
|
||||
|
||||
# Ensure Nginx can traverse the deploy path.
|
||||
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
||||
|
||||
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
|
||||
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
|
||||
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
|
||||
fi
|
||||
|
||||
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||
|
||||
# Ensure var/log exists and is writable by PHP (www-data)
|
||||
mkdir -p "${DEPLOY_DIR}/var/log"
|
||||
chown www-data:www-data "${DEPLOY_DIR}/var/log"
|
||||
chmod 775 "${DEPLOY_DIR}/var/log"
|
||||
|
||||
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
||||
echo "Clearing cache..."
|
||||
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
||||
|
||||
echo "Running migrations (if any)..."
|
||||
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
||||
else
|
||||
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
|
||||
fi
|
||||
36
src/Controller/RegenerateApiTokenController.php
Normal file
36
src/Controller/RegenerateApiTokenController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function bin2hex;
|
||||
use function random_bytes;
|
||||
|
||||
class RegenerateApiTokenController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$user->setApiToken($token);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['apiToken' => $token]);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
denormalizationContext: ['groups' => ['task:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'collaborators' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
||||
@@ -85,6 +85,16 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?User $assignee = null;
|
||||
|
||||
/** @var Collection<int, User> */
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'task_collaborator',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
)]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private Collection $collaborators;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
@@ -152,8 +162,9 @@ class Task
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->collaborators = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -245,6 +256,28 @@ class Task
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
public function getCollaborators(): Collection
|
||||
{
|
||||
return $this->collaborators;
|
||||
}
|
||||
|
||||
public function addCollaborator(User $user): static
|
||||
{
|
||||
if (!$this->collaborators->contains($user)) {
|
||||
$this->collaborators->add($user);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCollaborator(User $user): static
|
||||
{
|
||||
$this->collaborators->removeElement($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGroup(): ?TaskGroup
|
||||
{
|
||||
return $this->group;
|
||||
@@ -434,4 +467,15 @@ class Task
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateCollaborators(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null !== $this->assignee && $this->collaborators->contains($this->assignee)) {
|
||||
$context->buildViolation('The assignee cannot also be a collaborator.')
|
||||
->atPath('collaborators')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
||||
#[Groups(['me:read'])]
|
||||
private ?string $apiToken = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
|
||||
@@ -134,6 +134,19 @@ final class Serializer
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, User> $users
|
||||
*
|
||||
* @return list<array{id: ?int, username: ?string}>
|
||||
*/
|
||||
public static function users(Collection $users): array
|
||||
{
|
||||
return $users->map(fn (User $u) => [
|
||||
'id' => $u->getId(),
|
||||
'username' => $u->getUsername(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{id: ?int, title: ?string, color: ?string}
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,7 @@ class CreateTaskTool
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?array $collaboratorIds = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
?string $deadline = null,
|
||||
@@ -116,6 +117,18 @@ class CreateTaskTool
|
||||
$task->addTag($tag);
|
||||
}
|
||||
}
|
||||
if (null !== $collaboratorIds) {
|
||||
foreach ($collaboratorIds as $collaboratorId) {
|
||||
$collaborator = $this->userRepository->find($collaboratorId);
|
||||
if (null === $collaborator) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||
}
|
||||
if (null !== $assigneeId && $collaboratorId === $assigneeId) {
|
||||
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||
}
|
||||
$task->addCollaborator($collaborator);
|
||||
}
|
||||
}
|
||||
if (null !== $scheduledStart) {
|
||||
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||
}
|
||||
@@ -147,6 +160,7 @@ class CreateTaskTool
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($project),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
|
||||
@@ -34,19 +34,20 @@ class GetTaskTool
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::statusFull($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::group($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tagsWithColor($task->getTags()),
|
||||
'documents' => Serializer::documents($task->getDocuments()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::statusFull($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::group($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tagsWithColor($task->getTags()),
|
||||
'documents' => Serializer::documents($task->getDocuments()),
|
||||
'archived' => $task->isArchived(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, collaborator, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
@@ -22,6 +22,7 @@ class ListTasksTool
|
||||
?int $projectId = null,
|
||||
?int $statusId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $collaboratorId = null,
|
||||
?int $priorityId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
@@ -38,6 +39,7 @@ class ListTasksTool
|
||||
->leftJoin('t.status', 's')->addSelect('s')
|
||||
->leftJoin('t.priority', 'p')->addSelect('p')
|
||||
->leftJoin('t.assignee', 'a')->addSelect('a')
|
||||
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
|
||||
->leftJoin('t.project', 'pr')->addSelect('pr')
|
||||
->leftJoin('t.effort', 'e')->addSelect('e')
|
||||
->leftJoin('t.group', 'g')->addSelect('g')
|
||||
@@ -57,6 +59,9 @@ class ListTasksTool
|
||||
if (null !== $assigneeId) {
|
||||
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
|
||||
}
|
||||
if (null !== $collaboratorId) {
|
||||
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
|
||||
}
|
||||
if (null !== $priorityId) {
|
||||
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
|
||||
}
|
||||
@@ -75,17 +80,18 @@ class ListTasksTool
|
||||
}
|
||||
|
||||
return json_encode(array_map(fn ($task) => [
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
], array_values($tasks)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class UpdateTaskTool
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?array $collaboratorIds = null,
|
||||
?bool $archived = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
@@ -118,6 +119,22 @@ class UpdateTaskTool
|
||||
$task->addTag($tag);
|
||||
}
|
||||
}
|
||||
if (null !== $collaboratorIds) {
|
||||
foreach ($task->getCollaborators()->toArray() as $existing) {
|
||||
$task->removeCollaborator($existing);
|
||||
}
|
||||
$assignee = $task->getAssignee();
|
||||
foreach ($collaboratorIds as $collaboratorId) {
|
||||
$collaborator = $this->userRepository->find($collaboratorId);
|
||||
if (null === $collaborator) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||
}
|
||||
if (null !== $assignee && $collaborator->getId() === $assignee->getId()) {
|
||||
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||
}
|
||||
$task->addCollaborator($collaborator);
|
||||
}
|
||||
}
|
||||
if (null !== $archived) {
|
||||
$task->setArchived($archived);
|
||||
}
|
||||
@@ -147,6 +164,7 @@ class UpdateTaskTool
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
|
||||
@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
use const ENT_HTML5;
|
||||
use const ENT_QUOTES;
|
||||
|
||||
final class CalDavService
|
||||
{
|
||||
public function __construct(
|
||||
@@ -199,7 +202,7 @@ final class CalDavService
|
||||
$project = $task->getProject();
|
||||
$projectCode = null !== $project ? $project->getCode() : '';
|
||||
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('VEVENT', [
|
||||
@@ -225,7 +228,7 @@ final class CalDavService
|
||||
$project = $task->getProject();
|
||||
$projectCode = null !== $project ? $project->getCode() : '';
|
||||
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('VTODO', [
|
||||
@@ -337,6 +340,18 @@ final class CalDavService
|
||||
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
private function descriptionToPlainText(?string $value): string
|
||||
{
|
||||
if (null === $value || '' === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$stripped = strip_tags($value);
|
||||
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function getDayMap(): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user