Compare commits

...

30 Commits

Author SHA1 Message Date
gitea-actions
3fee678bd2 chore: bump version to v0.3.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 11:10:14 +00:00
be720178c2 feat(infra) : add maintenance mode during deployments
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Nginx returns a 503 page when maintenance.on exists. The deploy script
automatically enables/disables maintenance mode around the update.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:36:10 +02:00
gitea-actions
b50cfb5049 chore: bump version to v0.3.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 19s
Build Release Artefact / build (push) Successful in 2m5s
2026-04-01 10:01:14 +00:00
Matthieu
a5227b9936 fix : use sudo docker and port 8081 in deploy scripts
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:01:05 +02:00
gitea-actions
0d298db797 chore: bump version to v0.3.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 16s
Build Release Artefact / build (push) Successful in 2m2s
2026-04-01 09:24:34 +00:00
Matthieu
cbe71a1f32 fix : use malio-dev registry namespace instead of malio
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:24:26 +02:00
gitea-actions
a8fa8fd7e0 chore: bump version to v0.3.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 58s
Build Release Artefact / build (push) Successful in 2m13s
2026-04-01 09:15:52 +00:00
Matthieu
4aa2abd396 fix : remove COPY templates from Dockerfile.prod (dir does not exist)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:15:43 +02:00
gitea-actions
fa3326e99c chore: bump version to v0.3.14
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
Build Release Artefact / build (push) Successful in 1m54s
2026-04-01 09:07:03 +00:00
Matthieu
21e050ce29 feat : add Docker prodcution deployment
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-01 11:00:10 +02:00
gitea-actions
e480e2821b chore: bump version to v0.3.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 2m44s
2026-03-27 13:32:33 +00:00
Matthieu
2d7e9b9226 fix : use label instead of text for MalioSelect options in export drawer
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:20 +01:00
Matthieu
93e0c4052c chore : bump version to v0.3.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 3m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:33:42 +01:00
Matthieu
22373a0b87 refactor : migrate UI to Malio layer-ui components (MalioButton, MalioDrawer, MalioSelectCheckbox)
- Replace all AppDrawer with MalioDrawer across 10 drawer components
- Replace native <button> with MalioButton/MalioButtonIcon in all pages and components
- Fix TimeTrackingExportDrawer: use MalioSelectCheckbox for multi-select filters
- Add Malio design system colors (m-btn-*, m-disabled, m-surface) to tailwind.config.ts
- Align toggle button heights with MalioButton (h-[40px])

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:33:28 +01:00
gitea-actions
d7968af525 chore: bump version to v0.3.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m49s
2026-03-25 17:42:21 +00:00
df2a48c20d fix : remove double /api prefix in export URL
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
7f1c02256b fix : replace MalioButton with styled native button in export drawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
fdc9b8b60d fix : use correct useToast() API in export handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
1025fed0d1 feat : integrate export drawer with async background download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
0331d94ca5 feat : add TimeTrackingExportDrawer component with filters and period presets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
755c39a0f6 feat : extend export endpoint for multi-user, multi-project, client filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
8f8eeddd91 feat : add downloadExport async method to time-entries service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
548b101d82 feat : add i18n keys for export modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
88 changed files with 1960 additions and 913 deletions

View File

@@ -0,0 +1,61 @@
# Ticket Executor - Learnings
## Session 2026-03-17 (26 tickets)
### T-001 — Secrets .env
- **Pattern**: Replace secrets with `change_me_in_env_local` placeholder, move real values to `.env.local`
- **Gotcha**: `.env.local` must contain ALL overridden secrets
### T-002 — Security API Gitea
- **Pattern**: Ajouter `security: "is_granted('ROLE_USER')"` sur les opérations ApiResource
- **Learning**: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer
### T-003 — SVG Upload
- **Pattern**: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
- **Learning**: Toujours vérifier upload ET download controllers
### T-004 — MCP create-task / Repos numérotation
- **Gotcha critique**: PostgreSQL n'autorise PAS `FOR UPDATE` avec des fonctions d'agrégation (`MAX`)
- **Fix**: Utiliser `pg_advisory_xact_lock()` au lieu de `FOR UPDATE` pour les queries avec agrégation
- **Pattern**: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket
### T-005 — Filter ROLE_CLIENT projects
- **Pattern**: Créer une Doctrine Extension (`QueryCollectionExtensionInterface` + `QueryItemExtensionInterface`) pour filtrer par relation
- **Learning**: Symfony autoconfigure enregistre l'extension automatiquement
### T-006 — Block client doc upload
- **Pattern**: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
- **Learning**: Le portail client envoie un `clientTicket` IRI (pas de `task` IRI), donc le check sur `taskIri` non-vide suffit
### T-007 — MCP role checks
- **Pattern**: Injecter `Security` dans chaque Tool, vérifier au début de `__invoke()`
- **Learning**: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)
### T-009 — Password hashing
- **Pattern**: Champ `plainPassword` non-persisté, writable uniquement, hashé dans le Processor
- **Learning**: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API
### T-010 — Rate limiting
- **Gotcha**: `login_throttling` nécessite `symfony/rate-limiter` installé, pas juste dans composer.json
- **Learning**: Toujours vérifier que les packages sont installés, pas juste déclarés
### T-012 — Harmoniser repos numérotation
- **Pattern**: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
- **Learning**: Vérifier TOUS les appelants d'une méthode renommée
### T-015 — useAvatarService
- **Learning**: Quand on migre vers `useApi()`, ajouter la détection FormData pour ne pas écraser le Content-Type multipart
### T-020 — i18n
- **Pattern**: Ajouter `useI18n()` dans le setup script avant de pouvoir utiliser `t()` dans le JS
- **Learning**: Les templates peuvent utiliser `$t()` directement sans import
### T-022 — Retirer twig-bundle
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min

View File

@@ -0,0 +1,78 @@
---
name: ticket-executor
description: Execute Lesstime project tickets systematically - updates MCP statuses, follows project conventions, and logs learnings for self-improvement
---
# Ticket Executor Skill
## Purpose
Execute Lesstime project tickets end-to-end: read the ticket, implement the fix, update MCP status, and log learnings.
## Workflow
### 1. Receive Ticket
- Get ticket ID, title, description, tags (Backend/Frontend), priority, and current status
- Understand the scope from the title and description
### 2. Set Status to "En cours" (ID: 2)
- Use MCP `update-task` with `statusId: 2` before starting work
- MCP endpoint: `http://project.malio-dev.fr/_mcp`
- Auth: `Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64`
### 3. Analyze & Implement
Based on tag:
- **Backend**: Check `src/Entity/`, `src/State/`, `src/Controller/`, `src/Security/`, `config/`
- **Frontend**: Check `frontend/components/`, `frontend/composables/`, `frontend/pages/`, `frontend/services/`
Conventions to follow:
- PHP: `declare(strict_types=1)`, Symfony + PSR-12, API Platform patterns
- Frontend: TypeScript strict, `useApi()` composable, 4 spaces indent
- See CLAUDE.md for full conventions
### 4. Verify
- For Backend: `make php-cs-fixer-allow-risky` if PHP changed
- For Frontend: check TypeScript types, no `any`
- Read modified files to confirm correctness
### 5. Set Status to "Terminé" (ID: 5)
- Use MCP `update-task` with `statusId: 5` after successful implementation
### 6. Log Learnings
Append to `.claude/skills/ticket-executor/LEARNINGS.md`:
- What worked well
- Patterns discovered
- Gotchas encountered
- Time-saving shortcuts found
## MCP Session Management
The MCP HTTP transport requires a session. To call tools:
```bash
# Initialize session (get Mcp-Session-Id from response header)
curl -si -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}'
# Call tool (use Mcp-Session-Id from init response)
curl -s -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update-task","arguments":{"id":<taskId>,"statusId":<statusId>}}}'
```
## Status IDs
- 1 = A faire
- 2 = En cours
- 3 = Bloqué
- 4 = En attente de validation
- 5 = Terminé
## Learnings Integration
Before each ticket, read `LEARNINGS.md` to apply previous insights.
After each ticket, append new learnings. This creates a feedback loop that improves execution quality over time.
## Parallel Execution Rules
- Independent tickets (no shared files) can run in parallel via worktree agents
- Tickets modifying the same files must run sequentially
- Always verify no merge conflicts after parallel execution

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
.git
.gitea
.env.local
.env.test
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
var/
vendor/
LOG/
docs/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

View File

@@ -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.
# ===========================================================================

View File

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-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-dev/lesstime:${{ gitea.ref_name }}
docker push gitea.malio.fr/malio-dev/lesstime:latest

View File

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

2
.gitignore vendored
View File

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

View File

@@ -125,7 +125,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

0
LOG/xdebug.log Normal file
View File

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.10'
app.version: '0.3.21'

View File

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

335
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,335 @@
# Deploiement Docker — Lesstime
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer Lesstime.
Creer la base de donnees pour Lesstime :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE lesstime_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne Lesstime sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/lesstime
sudo chown -R $(whoami):$(whoami) /var/www/lesstime
cd /var/www/lesstime
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
container_name: lesstime-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/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}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
# App
DEFAULT_URI=https://project.malio-dev.fr
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
server_name project.malio-dev.fr;
client_max_body_size 55m;
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;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/lesstime.conf /etc/nginx/sites-enabled/lesstime.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp lesstime.sql user@serveur:/tmp/lesstime.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio lesstime_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio lesstime_prod < /tmp/lesstime.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/lesstime
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/lesstime.sql
```
### Structure finale du dossier
```
/var/www/lesstime/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/lesstime
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.3.12
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.3.12
```
---
## CI/CD
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-dev/lesstime:<tag>` et `:latest`
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
---
## Voir les logs
```bash
cd /var/www/lesstime
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (bare-metal)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/lesstime-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/lesstime/.env /var/www/lesstime-docker/.env
cp -a /var/www/lesstime/config/jwt /var/www/lesstime-docker/config/jwt
cp -a /var/www/lesstime/var/uploads /var/www/lesstime-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/lesstime-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/lesstime-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/lesstime-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/lesstime-docker /var/www/lesstime`

View File

@@ -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/lesstime.conf:ro
restart: unless-stopped
db:
image: postgres:16-alpine

View File

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

View File

@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
## 4. Installer le script de deploy
```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
```

View File

@@ -32,21 +32,19 @@
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
<MalioButton
:label="$t('bookstack.settings.save')"
button-class="w-auto px-4"
:disabled="isSaving"
>
{{ $t('bookstack.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('bookstack.settings.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
>
{{ $t('bookstack.settings.testConnection') }}
</button>
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un client"
@click="openCreate"
>
+ Ajouter un client
</button>
/>
</div>
<DataTable

View File

@@ -92,19 +92,21 @@
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="18"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
/>
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="18"
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
@click.stop="openDeleteConfirm(ticket)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
/>
</div>
</td>
</tr>
@@ -155,19 +157,18 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
/>
</div>
</div>
</div>
@@ -186,19 +187,19 @@
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="deleteModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-6"
:disabled="isDeleting"
@click="confirmDelete"
>
Supprimer
</button>
/>
</div>
</div>
</div>

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un effort"
@click="openCreate"
>
+ Ajouter un effort
</button>
/>
</div>
<DataTable

View File

@@ -24,21 +24,19 @@
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
<MalioButton
:label="$t('gitea.settings.save')"
button-class="w-auto px-4"
:disabled="isSaving"
>
{{ $t('gitea.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('gitea.settings.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
>
{{ $t('gitea.settings.testConnection') }}
</button>
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter une priorité"
@click="openCreate"
>
+ Ajouter une priorité
</button>
/>
</div>
<DataTable

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un statut"
@click="openCreate"
>
+ Ajouter un statut
</button>
/>
</div>
<DataTable

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un tag"
@click="openCreate"
>
+ Ajouter un tag
</button>
/>
</div>
<DataTable

View File

@@ -2,12 +2,13 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un utilisateur"
@click="openCreate"
>
+ Ajouter un utilisateur
</button>
/>
</div>
<DataTable

View File

@@ -37,21 +37,19 @@
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
</label>
<div class="flex gap-3">
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
<MalioButton
:label="$t('zimbra.settings.save')"
button-class="w-auto px-4"
:disabled="isSaving"
>
{{ $t('zimbra.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('zimbra.settings.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
>
{{ $t('zimbra.settings.testConnection') }}
</button>
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}

View File

@@ -29,22 +29,22 @@
</div>
<div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<button
<MalioButton
v-if="canEdit && !isEditing"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
variant="tertiary"
icon-name="mdi:pencil-outline"
icon-position="left"
button-class="w-auto px-3"
:label="$t('common.edit')"
@click="startEdit"
>
<Icon name="mdi:pencil-outline" size="16" />
{{ $t('common.edit') }}
</button>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
/>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
/>
</div>
</div>
</div>
@@ -90,21 +90,18 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancelEdit"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSaving"
@click="saveEdit"
>
{{ $t('common.save') }}
</button>
/>
</div>
</template>

View File

@@ -1,11 +1,13 @@
<template>
<div>
<!-- Trigger button -->
<button
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
<MalioButton
variant="tertiary"
icon-name="mdi:ticket-outline"
icon-position="left"
button-class="w-auto px-3 sm:px-4 shrink-0"
@click="open"
>
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span
v-if="totalCount > 0"
@@ -13,7 +15,7 @@
>
{{ totalCount }}
</span>
</button>
</MalioButton>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
@@ -33,13 +35,13 @@
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
/>
</div>
<!-- Filters -->
@@ -97,13 +99,13 @@
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="16"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="16" />
</button>
/>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18"
@@ -179,19 +181,18 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
/>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
@@ -35,16 +35,15 @@
/>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,18 +1,21 @@
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<div class="relative">
<MalioButtonIcon
icon="mdi:bell-outline"
aria-label="Notifications"
variant="ghost"
icon-size="24"
button-class="text-white hover:bg-primary-600"
@click="toggleDropdown"
/>
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white pointer-events-none"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
</div>
<Transition name="dropdown">
<div

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"
@@ -54,41 +54,44 @@
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
<MalioButton
variant="tertiary"
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleArchiveToggle"
>
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button>
<button
</MalioButton>
<MalioButton
v-if="project.taskCount === 0"
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
<Icon name="mdi:delete-outline" size="18" />
{{ $t('common.delete') }}
</button>
</MalioButton>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -3,20 +3,20 @@
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
<div class="flex items-center gap-3">
<button
type="button"
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
<MalioButton
variant="tertiary"
button-class="w-auto px-3"
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
@click="showArchived = !showArchived"
>
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
</button>
<button
/>
<MalioButton
v-if="!showArchived"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un groupe"
@click="openCreate"
>
+ Ajouter un groupe
</button>
/>
</div>
</div>
@@ -39,22 +39,20 @@
{{ item.description ?? '—' }}
</template>
<template #actions="{ item }">
<button
<MalioButton
v-if="!showArchived && canArchiveGroup(item)"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
variant="secondary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-3"
@click.stop="handleArchive(item)"
>
{{ $t('archive.archiveButton') }}
</button>
<button
/>
<MalioButton
v-if="showArchived"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
variant="secondary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-3"
@click.stop="handleUnarchive(item)"
>
{{ $t('archive.unarchiveButton') }}
</button>
/>
</template>
</DataTable>

View File

@@ -57,13 +57,14 @@
>
{{ link.title }}
</a>
<button
type="button"
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
<MalioButtonIcon
icon="mdi:close"
aria-label="Supprimer le lien"
variant="ghost"
icon-size="16"
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
@click="handleRemove(link.id)"
>
<Icon name="mdi:close" size="16" />
</button>
/>
</div>
</div>

View File

@@ -72,13 +72,14 @@
/>
<!-- Delete -->
<button
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
title="Supprimer"
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="22"
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
@click="emit('bulk-delete')"
>
<Icon name="mdi:delete-outline" size="22" />
</button>
/>
</div>
</div>
</template>

View File

@@ -29,13 +29,14 @@
</div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
>
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
/>
</div>
<div class="mt-2 flex items-center gap-1.5">

View File

@@ -32,14 +32,15 @@
</div>
<!-- Delete button -->
<button
<MalioButtonIcon
v-if="isAdmin"
type="button"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
icon="heroicons:x-mark"
aria-label="Supprimer"
variant="ghost"
icon-size="16"
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)"
>
<Icon name="heroicons:x-mark" class="h-4 w-4" />
</button>
/>
</div>
</div>
</div>

View File

@@ -12,28 +12,34 @@
ref="overlayRef"
>
<!-- Close button -->
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
<MalioButtonIcon
icon="heroicons:x-mark"
aria-label="Fermer"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('close')"
>
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
/>
<!-- Navigation arrows -->
<button
<MalioButtonIcon
v-if="hasPrev"
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
icon="heroicons:chevron-left"
aria-label="Précédent"
variant="ghost"
icon-size="24"
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('prev')"
>
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
</button>
<button
/>
<MalioButtonIcon
v-if="hasNext"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
icon="heroicons:chevron-right"
aria-label="Suivant"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('next')"
>
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
</button>
/>
<!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -10,16 +10,15 @@
/>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -38,24 +38,22 @@
<!-- Actions -->
<div class="flex gap-1">
<button
<MalioButtonIcon
v-if="activeTab === 'branches'"
type="button"
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
:title="$t('gitea.branch.copy')"
icon="mdi:content-copy"
:aria-label="$t('gitea.branch.copy')"
variant="ghost"
icon-size="14"
@click="handleCopy"
>
<Icon name="mdi:content-copy" size="14" />
</button>
<button
/>
<MalioButton
v-if="activeTab === 'branches'"
type="button"
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-2.5 py-1.5 text-xs"
:label="$t('gitea.branch.create')"
@click="showCreateForm = !showCreateForm"
>
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
{{ $t('gitea.branch.create') }}
</button>
/>
</div>
</div>
@@ -79,14 +77,12 @@
:label="$t('gitea.branch.baseBranch')"
input-class="w-full"
/>
<button
type="button"
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
<MalioButton
:label="isCreating ? '...' : $t('gitea.branch.create')"
button-class="w-auto px-4 mb-[2px] text-xs"
:disabled="isCreating"
@click="handleCreate"
>
{{ isCreating ? '...' : $t('gitea.branch.create') }}
</button>
/>
</div>
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
{{ branchPreview }}

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
@@ -25,34 +25,31 @@
</div>
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
<MalioButton
v-if="canArchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
variant="secondary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
/>
<MalioButton
v-if="canUnarchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
variant="secondary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -78,13 +78,14 @@
<!-- Right: timer top, avatar bottom -->
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
: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)"
>
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"

View File

@@ -27,13 +27,13 @@
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
/>
</div>
<!-- Client ticket link -->
@@ -417,48 +417,43 @@
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
<button
<MalioButton
v-if="isEditing"
type="button"
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
Supprimer
</button>
/>
<div class="flex gap-3">
<button
<MalioButton
v-if="canArchive"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
variant="tertiary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
/>
<MalioButton
v-if="canUnarchive"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</div>
</form>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -13,16 +13,15 @@
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -31,16 +31,15 @@
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -13,16 +13,15 @@
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -97,33 +97,30 @@
</div>
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
<MalioButton
v-if="isEditing"
type="button"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
@click="onDelete"
>
Supprimer
</button>
/>
<div class="flex gap-2">
<button
<MalioButton
v-if="isEditing"
type="button"
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
variant="secondary"
label="Dupliquer"
button-class="w-auto px-4"
@click="onDuplicate"
>
Dupliquer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-4"
@click="onSubmit"
/>
</div>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -54,13 +54,14 @@
</div>
<!-- Delete action -->
<button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
:title="$t('common.delete')"
<MalioButtonIcon
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="18"
button-class="shrink-0 text-neutral-300 opacity-0 hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
@click.stop="emit('deleteEntry', entry)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,205 @@
<template>
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
<div class="flex flex-col gap-6 p-4">
<!-- Period presets -->
<div>
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
<div class="flex flex-col gap-2">
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="currentMonth"
:label="$t('timeEntries.exportCurrentMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="lastMonth"
:label="$t('timeEntries.exportLastMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="custom"
:label="$t('timeEntries.exportCustomPeriod')"
/>
</div>
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
<input
v-model="customFrom"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
<input
v-model="customTo"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
</div>
</div>
<!-- User filter (admin only) -->
<div v-if="isAdmin" class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedUserIds"
:options="userOptions"
:label="$t('timeEntries.exportUsers')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Client filter -->
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedClientId"
:options="clientOptions"
:label="$t('timeEntries.exportClient')"
:empty-option-label="$t('timeEntries.exportAllClients')"
min-width="!w-full"
/>
</div>
<!-- Project filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedProjectIds"
:options="filteredProjectOptions"
:label="$t('timeEntries.exportProjects')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Tag filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedTagIds"
:options="tagOptions"
:label="$t('timeEntries.exportTags')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Export button -->
<MalioButton
:label="$t('timeEntries.export')"
icon-name="mdi:download"
icon-position="left"
button-class="w-full"
@click="doExport"
/>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
users: UserData[]
projects: Project[]
tags: TaskTag[]
clients: Client[]
}>()
const isOpen = defineModel<boolean>({ default: false })
const emit = defineEmits<{
(e: 'export', params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): void
}>()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
const customFrom = ref('')
const customTo = ref('')
const selectedUserIds = ref<number[]>([])
const selectedClientId = ref<number | null>(null)
const selectedProjectIds = ref<number[]>([])
const selectedTagIds = ref<number[]>([])
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id }))
)
const filteredProjectOptions = computed(() => {
let list = props.projects
if (selectedClientId.value) {
list = list.filter(p => p.client?.id === selectedClientId.value)
}
return list.map(p => ({ label: p.name, value: p.id }))
})
const tagOptions = computed(() =>
props.tags.map(t => ({ label: t.label, value: t.id }))
)
// Reset project selection when client changes
watch(selectedClientId, () => {
selectedProjectIds.value = []
})
function getDateRange(): { after: string; before: string } {
const now = new Date()
if (periodMode.value === 'currentMonth') {
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
if (periodMode.value === 'lastMonth') {
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const last = new Date(now.getFullYear(), now.getMonth(), 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
return {
after: customFrom.value,
before: customTo.value,
}
}
function doExport() {
const { after, before } = getDateRange()
if (!after || !before) return
emit('export', {
after,
before,
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
client: selectedClientId.value ?? undefined,
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
})
isOpen.value = false
}
</script>

View File

@@ -13,13 +13,13 @@
>
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
<button
type="button"
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="24"
@click="close"
>
<Icon name="mdi:close" size="24" />
</button>
/>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<slot />

View File

@@ -1,32 +1,34 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
<div class="flex h-full items-center justify-between">
<button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
<MalioButtonIcon
icon="mdi:menu"
aria-label="Menu"
variant="ghost"
icon-size="24"
button-class="lg:hidden text-white hover:bg-primary-600"
@click="ui.openMobileSidebar()"
>
<Icon name="mdi:menu" size="24" />
</button>
/>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
<button
type="button"
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
<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"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
/>
</div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<button
type="button"
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
<MalioButtonIcon
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
variant="ghost"
icon-size="22"
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
@click="ui.toggleDarkMode()"
>
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
</button>
/>
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />

View File

@@ -9,20 +9,18 @@
{{ $t('taskDocuments.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
@click="$emit('confirm')"
>
Supprimer
</button>
/>
</div>
</div>
</div>

View File

@@ -9,20 +9,18 @@
{{ $t('projects.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
>
{{ $t('common.delete') }}
</button>
/>
</div>
</div>
</div>

View File

@@ -21,21 +21,19 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-[red-600] px-4 py-2 text-sm font-semibold text-white hover:bg-[red-700] disabled:opacity-50"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="isProcessing"
@click="confirm"
>
{{ $t('common.delete') }}
</button>
/>
</div>
</div>
</div>

View File

@@ -9,20 +9,18 @@
{{ $t('tasks.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="cancel"
>
Annuler
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
@click="$emit('confirm')"
>
Supprimer
</button>
/>
</div>
</div>
</div>

View File

@@ -35,13 +35,15 @@
<td v-if="deletable || $slots.actions" class="px-4 py-3">
<div class="flex items-center gap-2">
<slot name="actions" :item="item" />
<button
<MalioButtonIcon
v-if="deletable"
class="text-neutral-400 transition-colors hover:text-red-500"
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="$emit('delete', item)"
>
<Icon name="mdi:delete-outline" size="20" />
</button>
/>
</div>
</td>
</tr>

View File

@@ -18,21 +18,18 @@
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
/>
<MalioButton
:label="$t('common.confirm')"
button-class="w-auto px-4"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
/>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.username"
@@ -70,16 +70,15 @@
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
Enregistrer
</button>
@click="handleSubmit"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</template>
<script setup lang="ts">

View File

@@ -162,7 +162,21 @@
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps",
"export": "Exporter"
"export": "Exporter",
"exportTitle": "Exporter les temps",
"exportCurrentMonth": "Mois en cours",
"exportLastMonth": "Mois dernier",
"exportCustomPeriod": "Période personnalisée",
"exportFrom": "Du",
"exportTo": "Au",
"exportUsers": "Utilisateurs",
"exportClient": "Client",
"exportProjects": "Projets",
"exportTags": "Tags",
"exportAllClients": "Tous les clients",
"exportLoading": "Export en cours...",
"exportSuccess": "Export terminé !",
"exportError": "Erreur lors de l'export."
},
"archive": {
"title": "Archives",

View File

@@ -7,7 +7,7 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.1.0",
"@malio/layer-ui": "^1.2.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -76,7 +76,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1038,6 +1037,7 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1047,6 +1047,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
@@ -1061,6 +1062,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1"
},
@@ -1073,6 +1075,7 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1085,6 +1088,7 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
@@ -1094,6 +1098,7 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
@@ -1169,6 +1174,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1178,6 +1184,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1191,6 +1198,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1204,6 +1212,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -2203,9 +2212,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.1.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.1.0/layer-ui-1.1.0.tgz",
"integrity": "sha512-mc+kOK+EDfo6ZZcE0/FaVnvDyIDJrigkgOzvL8rxnpljXEiRlKj5673e5e6ZIoOyKFqktzbJXzFr4V6UBD0wPg==",
"version": "1.2.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.0/layer-ui-1.2.0.tgz",
"integrity": "sha512-/D/p7Tz5t8xsZ+qL4kwBs2XXA/yNJpwF5C8pbSrz06Z8Je/Yut2J4KT1YpPHcfyFFE3TB8TpV0Okg/29aN6Ggg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -2483,7 +2492,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
"license": "MIT",
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2556,7 +2564,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.27",
"defu": "^6.1.4",
@@ -3203,7 +3210,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.95.0"
},
@@ -5309,7 +5315,8 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -5321,7 +5328,8 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -5662,7 +5670,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
@@ -5912,7 +5919,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5952,6 +5958,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6283,7 +6290,6 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6477,7 +6483,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6606,7 +6611,6 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6748,7 +6752,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -6785,7 +6788,6 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -7341,7 +7343,8 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -7847,6 +7850,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
@@ -7877,6 +7881,7 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7889,6 +7894,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7901,6 +7907,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -7913,6 +7920,7 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7922,6 +7930,7 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
@@ -7939,6 +7948,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7964,6 +7974,7 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -7976,6 +7987,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -8076,7 +8088,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -8104,13 +8117,15 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-npm-meta": {
"version": "1.4.0",
@@ -8155,6 +8170,7 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -8185,6 +8201,7 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -8201,6 +8218,7 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -8213,7 +8231,8 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -8746,6 +8765,7 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -9122,19 +9142,22 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -9212,6 +9235,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9472,6 +9496,7 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9556,6 +9581,7 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9947,7 +9973,8 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -10183,7 +10210,6 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.3.2",
"@nuxt/cli": "^3.33.0",
@@ -10454,6 +10480,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10505,7 +10532,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -10589,6 +10615,7 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10604,6 +10631,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10646,6 +10674,7 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10749,7 +10778,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10866,7 +10894,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11410,7 +11437,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11461,6 +11487,7 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11497,6 +11524,7 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11858,7 +11886,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12641,7 +12668,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -12982,6 +13008,7 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -13049,7 +13076,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13485,6 +13511,7 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13509,7 +13536,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13871,7 +13897,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -13936,7 +13961,6 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
@@ -13958,7 +13982,6 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -14011,6 +14034,7 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14179,6 +14203,7 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},

View File

@@ -11,7 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@malio/layer-ui": "^1.1.0",
"@malio/layer-ui": "^1.2.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -30,13 +30,12 @@
/>
</div>
<button
<MalioButton
label="Se connecter"
button-class="w-full"
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
</button>
/>
<p class="font-bold">v{{ version }}</p>
</form>
</div>

View File

@@ -324,15 +324,16 @@ onMounted(async () => {
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3"
@click="openTaskCreate"
>
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button>
</MalioButton>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"

View File

@@ -104,21 +104,19 @@
:placeholder="$t('clientTicket.rejectComment')"
/>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancelReject"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
/>
<MalioButton
variant="danger"
:label="$t('clientTicket.status.rejected')"
button-class="w-auto px-4"
:disabled="!rejectComment.trim()"
@click="confirmReject"
>
{{ $t('clientTicket.status.rejected') }}
</button>
/>
</div>
</div>
</div>

View File

@@ -74,13 +74,12 @@
>
{{ $t('common.cancel') }}
</NuxtLink>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
<MalioButton
:label="$t('portal.submitTicket')"
button-class="w-auto px-6"
:disabled="isSubmitting"
>
{{ $t('portal.submitTicket') }}
</button>
@click="handleSubmit"
/>
</div>
</form>
</div>

View File

@@ -26,15 +26,14 @@
/>
</label>
<button
<MalioButton
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
variant="danger"
button-class="w-auto px-4"
:disabled="removing"
:label="$t('profile.removeAvatar')"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
/>
</div>
</div>

View File

@@ -60,20 +60,20 @@
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<button
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="18"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
<button
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
title="Supprimer"
/>
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="18"
@click.stop="onDelete(ticket)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
/>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="20"
@@ -143,19 +143,18 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
/>
</div>
</div>
</div>

View File

@@ -4,15 +4,17 @@
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
<div class="flex items-center gap-2">
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3 shrink-0"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<span class="hidden sm:inline">Ajouter un ticket</span>
<span class="sm:hidden">Ticket</span>
</MalioButton>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
@@ -21,13 +23,12 @@
>
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
<button
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
title="Paramètres du projet"
<MalioButtonIcon
icon="heroicons:cog-6-tooth"
aria-label="Paramètres du projet"
variant="ghost"
@click="projectDrawerOpen = true"
>
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
</button>
/>
</div>
</div>

View File

@@ -4,23 +4,24 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
<div class="flex items-center gap-2 sm:gap-3">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
:class="showArchived
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'"
<MalioButton
variant="tertiary"
:icon-name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'"
icon-position="left"
button-class="w-auto px-3"
@click="toggleArchived"
>
<Icon :name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'" size="18" />
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
</button>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
</MalioButton>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3 shrink-0"
@click="openCreate"
>
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
</button>
<span class="hidden sm:inline">{{ $t('projects.addProject') }}</span>
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
</MalioButton>
</div>
</div>
</div>
@@ -44,12 +45,13 @@
{{ $t('common.archived') }}
</span>
</div>
<button
class="p-1 text-neutral-400 hover:text-primary-500"
<MalioButtonIcon
icon="mdi:pencil-outline"
aria-label="Modifier le projet"
variant="ghost"
icon-size="16"
@click.stop="openEdit(project)"
>
<Icon name="mdi:pencil-outline" size="16" />
</button>
/>
</div>
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
{{ project.description ?? '' }}

View File

@@ -3,27 +3,35 @@
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-primary-600 transition sm:px-4 sm:text-sm"
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="shrink-0"
@click="openCreateDrawer()"
>
<span class="hidden sm:inline">+ Ajouter une Activité</span>
<span class="sm:hidden">+ Activité</span>
</button>
<span class="hidden sm:inline">Ajouter une Activité</span>
<span class="sm:hidden">Activité</span>
</MalioButton>
</div>
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
<div class="flex shrink-0 items-center gap-1 h-8">
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<MalioButtonIcon
icon="mdi:chevron-left"
aria-label="Précédent"
variant="ghost"
@click="navigatePrev"
/>
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
{{ currentMonthLabel }}
</h2>
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
<MalioButtonIcon
icon="mdi:chevron-right"
aria-label="Suivant"
variant="ghost"
@click="navigateNext"
/>
</div>
<div class="flex items-center rounded-full bg-neutral-100 p-1">
@@ -76,13 +84,14 @@
/>
</div>
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
<MalioButton
:label="$t('timeEntries.export')"
variant="secondary"
icon-name="mdi:download"
icon-position="left"
button-class="w-auto px-4"
@click="exportDrawerOpen = true"
/>
</div>
</div>
@@ -128,6 +137,15 @@
@paste="onPaste"
@delete="onDelete"
/>
<TimeTrackingExportDrawer
v-model="exportDrawerOpen"
:users="users"
:projects="projects"
:tags="tags"
:clients="clients"
@export="onExport"
/>
</div>
</template>
@@ -136,6 +154,7 @@ import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
@@ -156,6 +175,8 @@ const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const tags = ref<TaskTag[]>([])
const clients = ref<Client[]>([])
const exportDrawerOpen = ref(false)
const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
@@ -305,38 +326,35 @@ async function onDelete(entry: TimeEntry) {
await loadEntries()
}
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
async function onExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}) {
const toast = useToast()
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
toast.info({ message: t('timeEntries.exportLoading') })
try {
const result = await timeEntryService.downloadExport(params)
const url = URL.createObjectURL(result.blob)
const a = document.createElement('a')
a.href = url
a.download = result.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success({ message: t('timeEntries.exportSuccess') })
} catch {
toast.error({ message: t('timeEntries.exportError') })
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
async function loadEntries() {
@@ -353,15 +371,17 @@ async function loadEntries() {
async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
api.get<HydraCollection<Client>>('/clients'),
])
users.value = extractHydraMembers(usersData)
projects.value = extractHydraMembers(projectsData)
tags.value = extractHydraMembers(typesData)
clients.value = extractHydraMembers(clientsData)
}
onMounted(async () => {

View File

@@ -53,20 +53,42 @@ export function useTimeEntryService() {
function getExportUrl(params: {
after: string
before: string
user?: number
project?: number
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.user) query.set('user', String(params.user))
if (params.project) query.set('project', String(params.project))
if (params.users?.length) {
params.users.forEach(id => query.append('users[]', String(id)))
}
if (params.client) query.set('client', String(params.client))
if (params.projects?.length) {
params.projects.forEach(id => query.append('projects[]', String(id)))
}
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/api/time_entries/export?${query.toString()}`
return `/time_entries/export?${query.toString()}`
}
return { getByDateRange, getActive, create, update, remove, getExportUrl }
async function downloadExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): Promise<{ blob: Blob; filename: string }> {
const url = getExportUrl(params)
const response = await api.getBlob(url)
const disposition = response.headers.get('content-disposition') ?? ''
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
const filename = filenameMatch?.[1] ?? `export-temps-${params.after}_${params.before}.xlsx`
return { blob: response.data, filename }
}
return { getByDateRange, getActive, create, update, remove, getExportUrl, downloadExport }
}

View File

@@ -19,6 +19,28 @@ export default <Partial<Config>>{
},
blue: {
500: '#056CF2'
},
m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)',
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',
muted: 'rgb(var(--m-muted) / <alpha-value>)',
bg: 'rgb(var(--m-bg) / <alpha-value>)',
surface: 'rgb(var(--m-surface) / <alpha-value>)',
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
danger: 'rgb(var(--m-danger) / <alpha-value>)',
success: 'rgb(var(--m-success) / <alpha-value>)',
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
}
}
}

22
infra/prod/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://lesstime_user:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=change-me
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
# App
DEFAULT_URI=https://project.malio-dev.fr

82
infra/prod/Dockerfile Normal file
View File

@@ -0,0 +1,82 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
RUN composer dump-autoload --optimize --no-dev
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
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
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
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
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

34
infra/prod/deploy.sh Executable file
View File

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

View File

@@ -0,0 +1,13 @@
services:
app:
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
container_name: lesstime-app
env_file: .env
ports:
- "8081:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

View File

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

View File

@@ -0,0 +1,14 @@
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;
}
}

70
infra/prod/nginx.conf Normal file
View File

@@ -0,0 +1,70 @@
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;
client_max_body_size 55m;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass 127.0.0.1:9000;
}
location ^~ /_mcp {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

View File

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

View File

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

View File

@@ -47,27 +47,65 @@ class TimeEntryExportController extends AbstractController
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
// Max range: 12 months
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// Authorization: non-admin users can only export their own data
$user = null;
// --- Users ---
$users = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $user */
$user = $this->security->getUser();
/** @var User $currentUser */
$currentUser = $this->security->getUser();
$users = [$currentUser];
} else {
$userId = $request->query->getInt('user');
if ($userId > 0) {
$user = $this->entityManager->getRepository(User::class)->find($userId);
/** @var int[] $userIds */
$userIds = array_filter(
array_map('intval', (array) $request->query->all('users')),
fn (int $id) => $id > 0,
);
if ([] !== $userIds) {
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
}
}
$project = null;
$projectId = $request->query->getInt('project');
if ($projectId > 0) {
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
// --- Client (filter projects by client) ---
$clientId = $request->query->getInt('client');
$clientProjects = null;
if ($clientId > 0) {
$clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]);
}
// --- Projects ---
$projects = null;
/** @var int[] $projectIds */
$projectIds = array_filter(
array_map('intval', (array) $request->query->all('projects')),
fn (int $id) => $id > 0,
);
if ([] !== $projectIds) {
$projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]);
}
// Merge: if both client and projects are set, intersect; if only client, use client projects
if (null !== $clientProjects && null !== $projects) {
$clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects);
$projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true)));
if ([] === $projects) {
$projects = null;
// No matching projects — force empty result by using a dummy condition
$entries = [];
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
} elseif (null !== $clientProjects) {
$projects = $clientProjects;
}
/** @var int[] $tagIds */
@@ -79,8 +117,8 @@ class TimeEntryExportController extends AbstractController
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$user,
$project,
$users ?: null,
$projects ?: null,
$tagIds ?: null,
);

View File

@@ -30,15 +30,17 @@ class TimeEntryRepository extends ServiceEntityRepository
}
/**
* @param null|int[] $tagIds
* @param null|User[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?User $user = null,
?Project $project = null,
?array $users = null,
?array $projects = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
@@ -49,15 +51,15 @@ class TimeEntryRepository extends ServiceEntityRepository
->orderBy('te.startedAt', 'ASC')
;
if (null !== $user) {
$qb->andWhere('te.user = :user')
->setParameter('user', $user)
if (null !== $users && [] !== $users) {
$qb->andWhere('te.user IN (:users)')
->setParameter('users', $users)
;
}
if (null !== $project) {
$qb->andWhere('te.project = :project')
->setParameter('project', $project)
if (null !== $projects && [] !== $projects) {
$qb->andWhere('te.project IN (:projects)')
->setParameter('projects', $projects)
;
}