Compare commits
101 Commits
v0.3.10
...
feat/in-ap
| Author | SHA1 | Date | |
|---|---|---|---|
| 930e1a1e37 | |||
| 55301c9c63 | |||
| 5fb7fbe66c | |||
| c1560468e6 | |||
| f86698e7cd | |||
| 1fd2c05db3 | |||
| 9f179e400d | |||
| 6a37349cf7 | |||
| 52b78d6bbc | |||
| e6d765f7bb | |||
| 5d42009348 | |||
| 8e4ddf00a8 | |||
| 18bc96082f | |||
| 6a084489ea | |||
| 80a41db34f | |||
| cf94635121 | |||
| eec61c089c | |||
| a9f87be8e5 | |||
| 25f2fc4b16 | |||
| a21914312a | |||
| f6a947ec15 | |||
| 03f3c85fd8 | |||
| 8a68e0d397 | |||
| 43e6d1aed2 | |||
| a3e3fd6da6 | |||
| b8b03048b6 | |||
|
|
ba86a71e12 | ||
|
|
6a942def3f | ||
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e | ||
|
|
258c6e9c17 | ||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
|
|
f1fd80d9ac | ||
|
|
24e3e8e989 | ||
|
|
47f2ab9cd4 | ||
|
|
36729f8f61 | ||
|
|
30b090852d | ||
|
|
f0c9568521 | ||
|
|
7c37eb58cb | ||
|
|
7a5b8dabff | ||
|
|
fef563be06 | ||
|
|
e14c707dfd | ||
|
|
fa7bb27ef5 | ||
|
|
21e9d2cab4 | ||
|
|
00ffcb1cf2 | ||
|
|
daba09472f | ||
|
|
f3208a481f | ||
|
|
a46542fcdd | ||
|
|
1ae2d9ac2c | ||
|
|
e41caa9cfe | ||
|
|
916f4ae101 | ||
| 45d389c67f | |||
|
|
7f12332cf6 | ||
| fe30f03b9f | |||
|
|
fc472d5dad | ||
| a0a2f27eac | |||
|
|
bd7adec2f0 | ||
| 9b6386c4ae | |||
|
|
9da1ae7ca1 | ||
| bc8bed3339 | |||
|
|
3fee678bd2 | ||
| be720178c2 | |||
|
|
eec0294f3e | ||
| 59a1c7956c | |||
|
|
e86949a1d7 | ||
|
|
7ca62bfc46 | ||
|
|
b60e4ae670 | ||
| ace52f8fc5 | |||
| 1ae9535516 | |||
|
|
b50cfb5049 | ||
|
|
a5227b9936 | ||
|
|
0d298db797 | ||
|
|
cbe71a1f32 | ||
|
|
a8fa8fd7e0 | ||
|
|
4aa2abd396 | ||
|
|
fa3326e99c | ||
|
|
21e050ce29 | ||
|
|
e480e2821b | ||
|
|
2d7e9b9226 | ||
|
|
93e0c4052c | ||
|
|
22373a0b87 | ||
|
|
d7968af525 | ||
| df2a48c20d | |||
| 7f1c02256b | |||
| fdc9b8b60d | |||
| 1025fed0d1 | |||
| 0331d94ca5 | |||
| 755c39a0f6 | |||
| 8f8eeddd91 | |||
| 548b101d82 |
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal file
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal 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
|
||||||
78
.claude/skills/ticket-executor/SKILL.md
Normal file
78
.claude/skills/ticket-executor/SKILL.md
Normal 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
24
.dockerignore
Normal 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
|
||||||
@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
|
|||||||
# Base de donnees (Doctrine / PostgreSQL)
|
# Base de donnees (Doctrine / PostgreSQL)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
|
||||||
# et injectees automatiquement par Docker Compose.
|
# et injectees automatiquement par Docker Compose.
|
||||||
# DATABASE_URL est construite a partir de ces variables.
|
# DATABASE_URL est construite a partir de ces variables.
|
||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
|||||||
ENCRYPTION_KEY=change_me_in_env_local
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Docker (docker/.env.docker)
|
# Docker (infra/dev/.env.docker)
|
||||||
#
|
#
|
||||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||||
# surcharger localement.
|
# surcharger localement.
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|||||||
30
.gitea/workflows/build-docker.yml
Normal file
30
.gitea/workflows/build-docker.yml
Normal 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
|
||||||
@@ -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 }}
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -28,5 +28,11 @@
|
|||||||
###< ide ###
|
###< ide ###
|
||||||
|
|
||||||
###> docker local ###
|
###> docker local ###
|
||||||
docker/.env.docker.local
|
infra/dev/.env.docker.local
|
||||||
###< docker local ###
|
###< docker local ###
|
||||||
|
|
||||||
|
###> local db dumps ###
|
||||||
|
*.sql.gz
|
||||||
|
*.sql.gz:Zone.Identifier
|
||||||
|
REVIEW.md
|
||||||
|
###< local db dumps ###
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
|
### Composants UI
|
||||||
|
|
||||||
|
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||||
@@ -125,7 +129,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Container PHP : `php-lesstime-fpm`
|
- Container PHP : `php-lesstime-fpm`
|
||||||
- Container Nginx : `nginx-lesstime`
|
- Container Nginx : `nginx-lesstime`
|
||||||
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
||||||
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
- Après modif nginx : `docker restart nginx-lesstime`
|
- Après modif nginx : `docker restart nginx-lesstime`
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
0
LOG/xdebug.log
Normal file
0
LOG/xdebug.log
Normal file
@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
|
|||||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||||
| PostgreSQL | 5435 | Base de données |
|
| PostgreSQL | 5435 | Base de données |
|
||||||
|
|
||||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ mcp:
|
|||||||
store: file
|
store: file
|
||||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||||
ttl: 3600
|
ttl: 3600
|
||||||
|
discovery:
|
||||||
|
scan_dirs: ['src']
|
||||||
|
exclude_dirs: ['DataFixtures']
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.10'
|
app.version: '0.4.0'
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
364
doc/deployment-docker.md
Normal file
364
doc/deployment-docker.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# 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 "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
|
echo "==> Pulling image..."
|
||||||
|
sudo docker compose pull
|
||||||
|
|
||||||
|
echo "==> Starting container..."
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
echo "==> Waiting for container to be ready..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "==> Extracting maintenance page..."
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
|
||||||
|
echo "==> Running migrations..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "==> Clearing cache..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|
||||||
|
echo "==> Disabling maintenance mode..."
|
||||||
|
rm -f maintenance.on
|
||||||
|
|
||||||
|
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
|
echo "==> Deployed v${VERSION}"
|
||||||
|
```
|
||||||
|
|
||||||
|
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;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
├── public/
|
||||||
|
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||||
|
└── 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`
|
||||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Configuration du mode maintenance (nginx hote)
|
||||||
|
|
||||||
|
Guide pour activer le support du mode maintenance pilote par Central.
|
||||||
|
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||||
|
|
||||||
|
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||||
|
|
||||||
|
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||||
|
|
||||||
|
## Ce qui a ete fait pour Lesstime
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||||
|
```
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verifier
|
||||||
|
|
||||||
|
- Depuis Central, activer la maintenance sur Lesstime
|
||||||
|
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||||
|
- Desactiver la maintenance depuis Central → le site revient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A faire pour Inventory
|
||||||
|
|
||||||
|
Meme procedure :
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/inventory
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||||
|
> ```bash
|
||||||
|
> mkdir -p public
|
||||||
|
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name inventory.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/inventory/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/inventory/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnement
|
||||||
|
|
||||||
|
```
|
||||||
|
Central (container)
|
||||||
|
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||||
|
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||||
|
▼
|
||||||
|
/var/www/lesstime/maintenance.on (hote)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
maintenance.html servie depuis /var/www/lesstime/public/
|
||||||
|
```
|
||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
php:
|
php:
|
||||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||||
build:
|
build:
|
||||||
context: ./docker/php
|
context: ./infra/dev
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||||
@@ -21,8 +21,8 @@ services:
|
|||||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||||
- ./LOG:/var/www/html/LOG
|
- ./LOG:/var/www/html/LOG
|
||||||
- uploads_data:/var/www/html/var/uploads
|
- uploads_data:/var/www/html/var/uploads
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
87
docs/claude-time-tracking-rule.md
Normal file
87
docs/claude-time-tracking-rule.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Règle Claude : Time Tracking automatique via Lesstime
|
||||||
|
|
||||||
|
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Time Tracking obligatoire
|
||||||
|
|
||||||
|
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
|
||||||
|
|
||||||
|
### Déclencheurs
|
||||||
|
|
||||||
|
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
|
||||||
|
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
|
||||||
|
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
|
||||||
|
|
||||||
|
### Méthode
|
||||||
|
|
||||||
|
Créer la time entry via **curl** sur l'API REST Lesstime :
|
||||||
|
|
||||||
|
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
|
||||||
|
- Body : `{"username":"admin","password":"admin"}`
|
||||||
|
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
|
||||||
|
|
||||||
|
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
|
||||||
|
- Body :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "/api/users/5",
|
||||||
|
"startedAt": "<ISO8601 avec timezone>",
|
||||||
|
"title": "<description courte de la tâche>",
|
||||||
|
"project": "/api/projects/<projectId>",
|
||||||
|
"tags": ["/api/task_tags/<tagId>"],
|
||||||
|
"task": "/api/tasks/<taskId>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
|
||||||
|
- Body : `{"stoppedAt": "<ISO8601>"}`
|
||||||
|
|
||||||
|
### Paramètres obligatoires
|
||||||
|
|
||||||
|
- **user** : TOUJOURS `/api/users/5` (Matthieu)
|
||||||
|
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
|
||||||
|
- **title** : description courte de la tâche en cours
|
||||||
|
- **project** : selon le projet (voir mapping ci-dessous)
|
||||||
|
|
||||||
|
### Tags (choisir selon le type de travail)
|
||||||
|
|
||||||
|
| Tag | ID | IRI |
|
||||||
|
|-----|----|-----|
|
||||||
|
| Backend | 3 | `/api/task_tags/3` |
|
||||||
|
| Frontend | 2 | `/api/task_tags/2` |
|
||||||
|
| IA | 7 | `/api/task_tags/7` |
|
||||||
|
| Infra | 5 | `/api/task_tags/5` |
|
||||||
|
| UI/UX | 4 | `/api/task_tags/4` |
|
||||||
|
| Maintenance | 6 | `/api/task_tags/6` |
|
||||||
|
| RDV | 1 | `/api/task_tags/1` |
|
||||||
|
| Réunion | 8 | `/api/task_tags/8` |
|
||||||
|
| Formation | 10 | `/api/task_tags/10` |
|
||||||
|
| Gestion projet | 9 | `/api/task_tags/9` |
|
||||||
|
|
||||||
|
### Mapping projets
|
||||||
|
|
||||||
|
| Projet | ID | IRI |
|
||||||
|
|--------|----|-----|
|
||||||
|
| Lesstime | 5 | `/api/projects/5` |
|
||||||
|
| Inventory | 7 | `/api/projects/7` |
|
||||||
|
| SIRH | 12 | `/api/projects/12` |
|
||||||
|
| Infrastructure | 13 | `/api/projects/13` |
|
||||||
|
| Malio UI | 11 | `/api/projects/11` |
|
||||||
|
| ERP Liot | 6 | `/api/projects/6` |
|
||||||
|
| Ferme | 8 | `/api/projects/8` |
|
||||||
|
| ADMIN | 16 | `/api/projects/16` |
|
||||||
|
| Maintenance-LIOT | 17 | `/api/projects/17` |
|
||||||
|
| Qualiopi | 14 | `/api/projects/14` |
|
||||||
|
| Vaultwarden | 18 | `/api/projects/18` |
|
||||||
|
|
||||||
|
### Règles
|
||||||
|
|
||||||
|
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
|
||||||
|
- **Toujours stopper le timer** en fin de tâche ou sur demande
|
||||||
|
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
|
||||||
|
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
|
||||||
|
- **Choisir les tags intelligemment** selon le type de travail effectué
|
||||||
@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
|
|||||||
## 4. Installer le script de deploy
|
## 4. Installer le script de deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
|||||||
## 7. Configurer Nginx
|
## 7. Configurer Nginx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
|
||||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|||||||
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Workflows de statuts par projet (Kanban custom)
|
||||||
|
|
||||||
|
**Date** : 2026-05-19
|
||||||
|
**Branche** : `feat/project-workflows`
|
||||||
|
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
|
||||||
|
|
||||||
|
## Reprise sur un autre poste
|
||||||
|
|
||||||
|
> **Pour le prochain Claude qui ouvre cette branche :**
|
||||||
|
>
|
||||||
|
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
|
||||||
|
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
|
||||||
|
> 3. **Aucun code applicatif n'a encore été écrit.**
|
||||||
|
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
|
||||||
|
> 5. **Validations Matthieu (2026-05-19)** :
|
||||||
|
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
|
||||||
|
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
|
||||||
|
> - Suppression d'`AdminStatusTab` → **OK**.
|
||||||
|
> - Ordre des étapes de livraison (§10) → **OK**.
|
||||||
|
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
|
||||||
|
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
|
||||||
|
|
||||||
|
## 1. Contexte et besoin
|
||||||
|
|
||||||
|
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
|
||||||
|
|
||||||
|
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
|
||||||
|
|
||||||
|
## 2. Modèle de données
|
||||||
|
|
||||||
|
### Nouvelle entité : `Workflow`
|
||||||
|
|
||||||
|
```
|
||||||
|
Workflow
|
||||||
|
- id int, PK
|
||||||
|
- name string(255), unique
|
||||||
|
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
|
||||||
|
- position int (pour l'ordre dans l'admin)
|
||||||
|
- statuses OneToMany → TaskStatus (inverse côté Workflow)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifications : `TaskStatus`
|
||||||
|
|
||||||
|
```
|
||||||
|
TaskStatus
|
||||||
|
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
|
||||||
|
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
|
||||||
|
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
|
||||||
|
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifications : `Project`
|
||||||
|
|
||||||
|
```
|
||||||
|
Project
|
||||||
|
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choix de design
|
||||||
|
|
||||||
|
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
|
||||||
|
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
|
||||||
|
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
|
||||||
|
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
|
||||||
|
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
|
||||||
|
|
||||||
|
## 3. Migrations BDD
|
||||||
|
|
||||||
|
Trois migrations Doctrine successives :
|
||||||
|
|
||||||
|
**M1 — `create_workflow_table`**
|
||||||
|
- Crée la table `workflow` (id, name, is_default, position)
|
||||||
|
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
|
||||||
|
|
||||||
|
**M2 — `add_workflow_to_task_status`**
|
||||||
|
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
|
||||||
|
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
|
||||||
|
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
|
||||||
|
- "À faire" → `todo`
|
||||||
|
- "En cours" → `in_progress`
|
||||||
|
- "Bloqué" → `blocked`
|
||||||
|
- "En attente de validation" → `review`
|
||||||
|
- "Terminé" → `done`
|
||||||
|
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
|
||||||
|
- Passe les 2 colonnes en `NOT NULL`
|
||||||
|
|
||||||
|
**M3 — `add_workflow_to_project`**
|
||||||
|
- Ajoute `project.workflow_id` nullable
|
||||||
|
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
|
||||||
|
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
|
||||||
|
|
||||||
|
## 4. Backend (Symfony / API Platform)
|
||||||
|
|
||||||
|
### Entités
|
||||||
|
|
||||||
|
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
|
||||||
|
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
|
||||||
|
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
|
||||||
|
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
|
||||||
|
|
||||||
|
### Sérialisation
|
||||||
|
|
||||||
|
- Groupe `workflow:read` pour l'API admin
|
||||||
|
- `task_status:read` ajoute `workflow` et `category`
|
||||||
|
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
|
||||||
|
|
||||||
|
### Endpoint dédié au switch
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/{id}/switch-workflow
|
||||||
|
Body: {
|
||||||
|
workflowId: int,
|
||||||
|
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
|
||||||
|
}
|
||||||
|
Security: ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
|
||||||
|
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
|
||||||
|
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
|
||||||
|
3. Transaction unique :
|
||||||
|
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
|
||||||
|
- `UPDATE project SET workflow_id = <new>`
|
||||||
|
4. Retourne `{ project, migratedTaskCount }`
|
||||||
|
|
||||||
|
### Validation cross-entity
|
||||||
|
|
||||||
|
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
|
||||||
|
|
||||||
|
### Suppression d'un Workflow
|
||||||
|
|
||||||
|
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
|
||||||
|
|
||||||
|
## 5. Frontend (Nuxt / Vue)
|
||||||
|
|
||||||
|
### Nouveaux fichiers
|
||||||
|
|
||||||
|
- `frontend/services/workflows.ts` — service API CRUD
|
||||||
|
- `frontend/services/dto/workflow.ts` — type TS
|
||||||
|
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
|
||||||
|
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
|
||||||
|
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
|
||||||
|
|
||||||
|
### Modifications
|
||||||
|
|
||||||
|
- `frontend/components/admin/AdminStatusTab.vue` :
|
||||||
|
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
|
||||||
|
- `frontend/components/project/ProjectDrawer.vue` :
|
||||||
|
- Affiche le workflow actuel
|
||||||
|
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
|
||||||
|
- `frontend/pages/projects/[id]/index.vue` :
|
||||||
|
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
|
||||||
|
- Le kanban a les colonnes du workflow du projet
|
||||||
|
- `frontend/pages/projects/[id]/archives.vue` :
|
||||||
|
- Filtre statut limité au workflow du projet
|
||||||
|
- `frontend/pages/my-tasks.vue` :
|
||||||
|
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
|
||||||
|
- Chaque card affiche le statut spécifique en badge
|
||||||
|
- Vue liste : pas de changement
|
||||||
|
- `frontend/components/task/TaskModal.vue` :
|
||||||
|
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
|
||||||
|
- `frontend/components/task/TaskBulkActions.vue` :
|
||||||
|
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
|
||||||
|
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
|
||||||
|
|
||||||
|
### `ProjectWorkflowSwitchModal.vue` — détails UX
|
||||||
|
|
||||||
|
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
|
||||||
|
- Étape 2 (après sélection) : tableau de mapping
|
||||||
|
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
|
||||||
|
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
|
||||||
|
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
|
||||||
|
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
|
||||||
|
- Footer :
|
||||||
|
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
|
||||||
|
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
|
||||||
|
|
||||||
|
## 6. MCP
|
||||||
|
|
||||||
|
| Tool | Changement |
|
||||||
|
|---|---|
|
||||||
|
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
|
||||||
|
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
|
||||||
|
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
|
||||||
|
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
|
||||||
|
|
||||||
|
## 7. Permissions
|
||||||
|
|
||||||
|
| Action | Rôle requis |
|
||||||
|
|---|---|
|
||||||
|
| Lire les workflows et leurs statuts | `ROLE_USER` |
|
||||||
|
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
|
||||||
|
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
|
||||||
|
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
|
||||||
|
|
||||||
|
## 8. Hors scope (YAGNI explicites)
|
||||||
|
|
||||||
|
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
|
||||||
|
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
|
||||||
|
- **Versioning des workflows** (historique des modifs) — pas demandé
|
||||||
|
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
|
||||||
|
|
||||||
|
## 9. Risques et limites
|
||||||
|
|
||||||
|
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
|
||||||
|
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
|
||||||
|
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
|
||||||
|
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
|
||||||
|
|
||||||
|
## 10. Étapes de livraison suggérées
|
||||||
|
|
||||||
|
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
|
||||||
|
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
|
||||||
|
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
|
||||||
|
4. Service frontend `workflows` + types DTO
|
||||||
|
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
|
||||||
|
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
|
||||||
|
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
|
||||||
|
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
|
||||||
|
9. Adaptation `TaskBulkActions` et autres écrans transverses
|
||||||
|
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
|
||||||
|
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
|
||||||
|
|
||||||
|
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.
|
||||||
@@ -10,21 +10,17 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenId"
|
v-model="form.tokenId"
|
||||||
:label="$t('bookstack.settings.tokenId')"
|
:label="$t('bookstack.settings.tokenId')"
|
||||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenSecret"
|
v-model="form.tokenSecret"
|
||||||
:label="$t('bookstack.settings.tokenSecret')"
|
:label="$t('bookstack.settings.tokenSecret')"
|
||||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||||
@@ -32,21 +28,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('bookstack.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('bookstack.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('bookstack.settings.testConnection')"
|
||||||
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"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('bookstack.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||||
<button
|
<MalioButton
|
||||||
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 client"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un client
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -92,19 +92,21 @@
|
|||||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||||
<td class="px-3 py-3">
|
<td class="px-3 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
icon="mdi:swap-horizontal"
|
||||||
:title="$t('clientTicket.changeStatus')"
|
:aria-label="$t('clientTicket.changeStatus')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
@click.stop="openStatusChange(ticket)"
|
@click.stop="openStatusChange(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:swap-horizontal" size="18" />
|
<MalioButtonIcon
|
||||||
</button>
|
icon="mdi:delete-outline"
|
||||||
<button
|
aria-label="Supprimer"
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
|
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||||
@click.stop="openDeleteConfirm(ticket)"
|
@click.stop="openDeleteConfirm(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -155,19 +157,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="statusModalOpen = false"
|
@click="statusModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
label="Confirmer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isUpdatingStatus"
|
:disabled="isUpdatingStatus"
|
||||||
@click="confirmStatusChange"
|
@click="confirmStatusChange"
|
||||||
>
|
/>
|
||||||
Confirmer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,19 +187,19 @@
|
|||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
<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>
|
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="deleteModalOpen = false"
|
@click="deleteModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isDeleting"
|
:disabled="isDeleting"
|
||||||
@click="confirmDelete"
|
@click="confirmDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||||
<button
|
<MalioButton
|
||||||
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 effort"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un effort
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -11,12 +11,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.token"
|
v-model="form.token"
|
||||||
:label="$t('gitea.settings.token')"
|
:label="$t('gitea.settings.token')"
|
||||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('gitea.settings.tokenConfigured') }}
|
{{ $t('gitea.settings.tokenConfigured') }}
|
||||||
@@ -24,21 +22,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('gitea.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('gitea.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('gitea.settings.testConnection')"
|
||||||
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"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('gitea.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||||
<button
|
<MalioButton
|
||||||
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 une priorité"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter une priorité
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
+ Ajouter un statut
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:items="items"
|
|
||||||
:loading="isLoading"
|
|
||||||
empty-message="Aucun statut trouvé."
|
|
||||||
deletable
|
|
||||||
@row-click="openEdit"
|
|
||||||
@delete="requestDelete"
|
|
||||||
>
|
|
||||||
<template #cell-color="{ item }">
|
|
||||||
<span
|
|
||||||
class="inline-block h-6 w-6 rounded-full"
|
|
||||||
:style="{ backgroundColor: item.color }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<TaskStatusDrawer
|
|
||||||
v-model="drawerOpen"
|
|
||||||
:item="selectedItem"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDeleteStatusModal
|
|
||||||
v-model="confirmModalOpen"
|
|
||||||
:status-label="statusToDelete?.label ?? ''"
|
|
||||||
:task-count="affectedTaskCount"
|
|
||||||
:available-statuses="reassignTargets"
|
|
||||||
@confirm="onConfirmDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
|
||||||
import type { Task } from '~/services/dto/task'
|
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskService } from '~/services/tasks'
|
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ key: 'label', label: 'Libellé', primary: true },
|
|
||||||
{ key: 'color', label: 'Couleur' },
|
|
||||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const taskService = useTaskService()
|
|
||||||
|
|
||||||
const items = ref<TaskStatus[]>([])
|
|
||||||
const tasks = ref<Task[]>([])
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const drawerOpen = ref(false)
|
|
||||||
const selectedItem = ref<TaskStatus | null>(null)
|
|
||||||
const confirmModalOpen = ref(false)
|
|
||||||
const statusToDelete = ref<TaskStatus | null>(null)
|
|
||||||
|
|
||||||
const affectedTaskCount = computed(() => {
|
|
||||||
if (!statusToDelete.value) return 0
|
|
||||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const reassignTargets = computed(() => {
|
|
||||||
if (!statusToDelete.value) return items.value
|
|
||||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadItems() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const [statuses, allTasks] = await Promise.all([
|
|
||||||
statusService.getAll(),
|
|
||||||
taskService.getAll(),
|
|
||||||
])
|
|
||||||
items.value = statuses
|
|
||||||
tasks.value = allTasks
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
selectedItem.value = null
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(item: TaskStatus) {
|
|
||||||
selectedItem.value = item
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestDelete(item: TaskStatus) {
|
|
||||||
statusToDelete.value = item
|
|
||||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
|
||||||
if (count === 0) {
|
|
||||||
await statusService.remove(item.id)
|
|
||||||
await loadItems()
|
|
||||||
} else {
|
|
||||||
confirmModalOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onConfirmDelete(targetStatusId: number | null) {
|
|
||||||
if (!statusToDelete.value) return
|
|
||||||
|
|
||||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
|
||||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
|
||||||
)
|
|
||||||
|
|
||||||
await statusService.remove(statusToDelete.value.id)
|
|
||||||
confirmModalOpen.value = false
|
|
||||||
statusToDelete.value = null
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaved() {
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadItems()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||||
<button
|
<MalioButton
|
||||||
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 tag"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un tag
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||||
<button
|
<MalioButton
|
||||||
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 utilisateur"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un utilisateur
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
|
||||||
|
<MalioButton
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('workflows.addWorkflow')"
|
||||||
|
@click="openCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun workflow trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="requestDelete"
|
||||||
|
>
|
||||||
|
<template #cell-isDefault="{ item }">
|
||||||
|
<span
|
||||||
|
v-if="item.isDefault"
|
||||||
|
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
|
||||||
|
>
|
||||||
|
{{ $t('workflows.isDefault') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-statusCount="{ item }">
|
||||||
|
{{ item.statuses.length }}
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<WorkflowDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'name', label: t('workflows.name'), primary: true },
|
||||||
|
{ key: 'isDefault', label: t('workflows.isDefault') },
|
||||||
|
{ key: 'statusCount', label: t('workflows.statuses') },
|
||||||
|
{ key: 'position', label: 'Position' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
|
||||||
|
const items = ref<Workflow[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<Workflow | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await workflowService.getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: Workflow) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDelete(item: Workflow) {
|
||||||
|
try {
|
||||||
|
await workflowService.remove(item.id)
|
||||||
|
await loadItems()
|
||||||
|
} catch {
|
||||||
|
// Toast d'erreur déjà émis par useApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -22,11 +22,10 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
:label="$t('zimbra.settings.password')"
|
:label="$t('zimbra.settings.password')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||||
@@ -37,21 +36,19 @@
|
|||||||
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('zimbra.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('zimbra.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('zimbra.settings.testConnection')"
|
||||||
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"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('zimbra.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<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') }}
|
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
|
||||||
|
|||||||
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:label="$t('workflows.name')"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
|
||||||
|
@blur="touched.name = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="isDefault"
|
||||||
|
v-model="form.isDefault"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label for="isDefault" class="text-sm font-medium text-neutral-700">
|
||||||
|
{{ $t('workflows.isDefault') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
|
||||||
|
<MalioButton
|
||||||
|
type="button"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 py-1 text-xs"
|
||||||
|
:label="$t('workflows.addStatus')"
|
||||||
|
@click="addStatus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(s, idx) in form.statuses"
|
||||||
|
:key="idx"
|
||||||
|
class="rounded border border-neutral-200 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="s.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="s.category"
|
||||||
|
class="h-10 rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
aria-label="Catégorie"
|
||||||
|
>
|
||||||
|
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
|
||||||
|
{{ c.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
@click="removeStatus(idx)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
<ColorPicker v-model="s.color" />
|
||||||
|
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
|
||||||
|
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
|
||||||
|
{{ $t('archive.statusFinal') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col text-xs text-neutral-700">
|
||||||
|
Position
|
||||||
|
<input
|
||||||
|
v-model.number="s.position"
|
||||||
|
type="number"
|
||||||
|
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<MalioButton
|
||||||
|
label="Enregistrer"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||||
|
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: Workflow | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: v => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
type StatusForm = {
|
||||||
|
id?: number
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
position: number
|
||||||
|
isFinal: boolean
|
||||||
|
category: StatusCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
name: string
|
||||||
|
isDefault: boolean
|
||||||
|
statuses: StatusForm[]
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
isDefault: false,
|
||||||
|
statuses: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({ name: false })
|
||||||
|
|
||||||
|
const categoryOptions: { value: StatusCategory, label: string }[] = [
|
||||||
|
{ value: 'todo', label: t('workflows.categories.todo') },
|
||||||
|
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
|
||||||
|
{ value: 'blocked', label: t('workflows.categories.blocked') },
|
||||||
|
{ value: 'review', label: t('workflows.categories.review') },
|
||||||
|
{ value: 'done', label: t('workflows.categories.done') },
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
if (props.item) {
|
||||||
|
form.name = props.item.name
|
||||||
|
form.isDefault = props.item.isDefault
|
||||||
|
form.statuses = props.item.statuses.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
form.name = ''
|
||||||
|
form.isDefault = false
|
||||||
|
form.statuses = []
|
||||||
|
}
|
||||||
|
touched.name = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function addStatus() {
|
||||||
|
form.statuses.push({
|
||||||
|
label: '',
|
||||||
|
color: '#222783',
|
||||||
|
position: form.statuses.length,
|
||||||
|
isFinal: false,
|
||||||
|
category: 'todo',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStatus(idx: number) {
|
||||||
|
form.statuses.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
const statusService = useTaskStatusService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.name = true
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await workflowService.update(props.item.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
isDefault: form.isDefault,
|
||||||
|
position: props.item.position,
|
||||||
|
})
|
||||||
|
await syncStatuses(props.item)
|
||||||
|
} else {
|
||||||
|
const created = await workflowService.create({
|
||||||
|
name: form.name.trim(),
|
||||||
|
isDefault: form.isDefault,
|
||||||
|
position: 0,
|
||||||
|
})
|
||||||
|
for (const s of form.statuses) {
|
||||||
|
const payload: TaskStatusWrite = {
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
workflow: `/api/workflows/${created.id}`,
|
||||||
|
}
|
||||||
|
await statusService.create(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncStatuses(workflow: Workflow) {
|
||||||
|
const existingIds = new Set(workflow.statuses.map(s => s.id))
|
||||||
|
const keptIds = new Set<number>()
|
||||||
|
|
||||||
|
for (const s of form.statuses) {
|
||||||
|
if (s.id) {
|
||||||
|
keptIds.add(s.id)
|
||||||
|
await statusService.update(s.id, {
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await statusService.create({
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
workflow: `/api/workflows/${workflow.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of existingIds) {
|
||||||
|
if (id && !keptIds.has(id)) {
|
||||||
|
await statusService.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -29,22 +29,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canEdit && !isEditing"
|
v-if="canEdit && !isEditing"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
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"
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3"
|
||||||
|
:label="$t('common.edit')"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:pencil-outline" size="16" />
|
<MalioButtonIcon
|
||||||
{{ $t('common.edit') }}
|
icon="mdi:close"
|
||||||
</button>
|
aria-label="Fermer"
|
||||||
<button
|
variant="ghost"
|
||||||
type="button"
|
icon-size="20"
|
||||||
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"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,14 +66,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
<MalioInputRichText
|
||||||
{{ $t('clientTicket.description') }}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editForm.description"
|
v-model="editForm.description"
|
||||||
rows="5"
|
:label="$t('clientTicket.description')"
|
||||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
min-height="180px"
|
||||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,21 +86,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancelEdit"
|
@click="cancelEdit"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
:label="$t('common.save')"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
@click="saveEdit"
|
@click="saveEdit"
|
||||||
>
|
/>
|
||||||
{{ $t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,7 +125,13 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
group-class="mt-1"
|
||||||
|
/>
|
||||||
|
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL (if bug) -->
|
<!-- URL (if bug) -->
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Trigger button -->
|
<!-- Trigger button -->
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
variant="tertiary"
|
||||||
|
icon-name="mdi:ticket-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||||
@click="open"
|
@click="open"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
|
|
||||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="totalCount > 0"
|
v-if="totalCount > 0"
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
>
|
>
|
||||||
{{ totalCount }}
|
{{ totalCount }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<Teleport v-if="isOpen" to="body">
|
<Teleport v-if="isOpen" to="body">
|
||||||
@@ -33,13 +35,13 @@
|
|||||||
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
<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>
|
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
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"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -97,13 +99,13 @@
|
|||||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
icon="mdi:swap-horizontal"
|
||||||
:title="$t('clientTicket.changeStatus')"
|
:aria-label="$t('clientTicket.changeStatus')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="16"
|
||||||
@click.stop="openStatusChange(ticket)"
|
@click.stop="openStatusChange(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:swap-horizontal" size="16" />
|
|
||||||
</button>
|
|
||||||
<Icon
|
<Icon
|
||||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||||
size="18"
|
size="18"
|
||||||
@@ -114,7 +116,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
@@ -179,19 +186,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="statusModalOpen = false"
|
@click="statusModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
label="Confirmer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isUpdatingStatus"
|
:disabled="isUpdatingStatus"
|
||||||
@click="confirmStatusChange"
|
@click="confirmStatusChange"
|
||||||
>
|
/>
|
||||||
Confirmer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
@@ -35,16 +35,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="bellRef" class="relative">
|
<div ref="bellRef" class="relative">
|
||||||
<button
|
<div class="relative">
|
||||||
type="button"
|
<MalioButtonIcon
|
||||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
icon="mdi:bell-outline"
|
||||||
@click="toggleDropdown"
|
aria-label="Notifications"
|
||||||
>
|
variant="ghost"
|
||||||
<Icon name="mdi:bell-outline" size="24" />
|
icon-size="24"
|
||||||
|
button-class="text-white hover:bg-primary-600"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="unreadCount > 0"
|
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 }}
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
@@ -54,41 +54,69 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||||
<button
|
<MalioButton
|
||||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
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"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchiveToggle"
|
@click="handleArchiveToggle"
|
||||||
>
|
>
|
||||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
|
||||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||||
</button>
|
</MalioButton>
|
||||||
<button
|
<MalioButton
|
||||||
v-if="project.taskCount === 0"
|
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"
|
:disabled="isSubmitting"
|
||||||
@click="confirmDeleteOpen = true"
|
@click="confirmDeleteOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
{{ $t('common.delete') }}
|
{{ $t('common.delete') }}
|
||||||
</button>
|
</MalioButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
|
||||||
|
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
|
||||||
|
</div>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManageWorkflows"
|
||||||
|
type="button"
|
||||||
|
icon-name="mdi:swap-horizontal"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 py-1 text-xs"
|
||||||
|
:label="$t('workflows.switchTitle')"
|
||||||
|
@click="switchModalOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDeleteProjectModal
|
<ConfirmDeleteProjectModal
|
||||||
v-model="confirmDeleteOpen"
|
v-model="confirmDeleteOpen"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
</AppDrawer>
|
|
||||||
|
<ProjectWorkflowSwitchModal
|
||||||
|
v-if="props.project"
|
||||||
|
v-model="switchModalOpen"
|
||||||
|
:project="props.project"
|
||||||
|
@switched="onWorkflowSwitched"
|
||||||
|
/>
|
||||||
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -119,6 +147,15 @@ const isOpen = computed({
|
|||||||
const isEditing = computed(() => !!props.project)
|
const isEditing = computed(() => !!props.project)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const confirmDeleteOpen = ref(false)
|
const confirmDeleteOpen = ref(false)
|
||||||
|
const switchModalOpen = ref(false)
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
function onWorkflowSwitched() {
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const { listRepositories } = useGiteaService()
|
const { listRepositories } = useGiteaService()
|
||||||
const giteaRepos = ref<GiteaRepository[]>([])
|
const giteaRepos = ref<GiteaRepository[]>([])
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
button-class="w-auto px-3"
|
||||||
|
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||||
@click="showArchived = !showArchived"
|
@click="showArchived = !showArchived"
|
||||||
>
|
/>
|
||||||
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!showArchived"
|
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"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un groupe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,25 +36,23 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-description="{ item }">
|
<template #cell-description="{ item }">
|
||||||
{{ item.description ?? '—' }}
|
{{ stripRichText(item.description) || '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ item }">
|
<template #actions="{ item }">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="!showArchived && canArchiveGroup(item)"
|
v-if="!showArchived && canArchiveGroup(item)"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-3"
|
||||||
@click.stop="handleArchive(item)"
|
@click.stop="handleArchive(item)"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="showArchived"
|
v-if="showArchived"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-3"
|
||||||
@click.stop="handleUnarchive(item)"
|
@click.stop="handleUnarchive(item)"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
@@ -73,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number
|
projectId: number
|
||||||
|
|||||||
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click="close" />
|
||||||
|
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-col gap-5">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="targetWorkflowId"
|
||||||
|
:options="targetOptions"
|
||||||
|
:label="$t('workflows.switchTargetLabel')"
|
||||||
|
empty-option-label="—"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||||
|
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
|
||||||
|
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-xs text-neutral-500">
|
||||||
|
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
|
||||||
|
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<span
|
||||||
|
v-if="row.source"
|
||||||
|
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||||
|
:style="{ backgroundColor: row.source.color }"
|
||||||
|
/>
|
||||||
|
{{ row.source?.label ?? $t('myTasks.backlog') }}
|
||||||
|
<span class="ml-1 text-xs text-neutral-400">
|
||||||
|
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
v-model="row.targetId"
|
||||||
|
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="s in targetWorkflow.statuses"
|
||||||
|
:key="s.id"
|
||||||
|
:value="s.id"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('workflows.switchConfirm')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="!canConfirm || isSubmitting"
|
||||||
|
@click="confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
project: Project
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'switched'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const workflows = ref<Workflow[]>([])
|
||||||
|
const projectTasks = ref<Task[]>([])
|
||||||
|
const targetWorkflowId = ref<number | null>(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
|
||||||
|
const targetOptions = computed(() =>
|
||||||
|
workflows.value
|
||||||
|
.filter(w => w.id !== props.project.workflow.id)
|
||||||
|
.map(w => ({ label: w.name, value: w.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const targetWorkflow = computed<Workflow | null>(() =>
|
||||||
|
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
sourceId: number | null
|
||||||
|
source: TaskStatus | null
|
||||||
|
targetId: number | null
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingRows = ref<Row[]>([])
|
||||||
|
|
||||||
|
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
|
||||||
|
if (!source) return null
|
||||||
|
const sameCat = target.statuses
|
||||||
|
.filter(s => s.category === source.category)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
return sameCat[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(targetWorkflow, (tw) => {
|
||||||
|
if (!tw) {
|
||||||
|
mappingRows.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const usedStatusIds = new Map<number | null, number>()
|
||||||
|
for (const t of projectTasks.value) {
|
||||||
|
const key = t.status?.id ?? null
|
||||||
|
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
|
||||||
|
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
source,
|
||||||
|
targetId: smartPrefill(source, tw),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const canConfirm = computed(() => {
|
||||||
|
if (!targetWorkflow.value) return false
|
||||||
|
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (open) => {
|
||||||
|
if (!open) return
|
||||||
|
targetWorkflowId.value = null
|
||||||
|
const [allWorkflows, tasks] = await Promise.all([
|
||||||
|
workflowService.getAll(),
|
||||||
|
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
|
||||||
|
])
|
||||||
|
workflows.value = allWorkflows
|
||||||
|
projectTasks.value = tasks
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!targetWorkflow.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const mapping: Record<string, number | null> = {}
|
||||||
|
for (const r of mappingRows.value) {
|
||||||
|
if (r.sourceId !== null) {
|
||||||
|
mapping[String(r.sourceId)] = r.targetId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await workflowService.switchOnProject(props.project.id, {
|
||||||
|
workflowId: targetWorkflow.value.id,
|
||||||
|
mapping,
|
||||||
|
})
|
||||||
|
emit('switched')
|
||||||
|
close()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -57,13 +57,14 @@
|
|||||||
>
|
>
|
||||||
{{ link.title }}
|
{{ link.title }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
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)"
|
@click="handleRemove(link.id)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||||
<!-- Bulk status -->
|
<!-- Bulk status (scoped to single project's workflow) -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="!isMultiProject"
|
||||||
:model-value="null"
|
:model-value="null"
|
||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -25,6 +26,13 @@
|
|||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
|
||||||
|
title="Sélection multi-projets — le statut dépend du workflow de chaque projet"
|
||||||
|
>
|
||||||
|
Status —
|
||||||
|
</span>
|
||||||
<!-- Bulk user -->
|
<!-- Bulk user -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="null"
|
:model-value="null"
|
||||||
@@ -72,25 +80,28 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
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"
|
icon="mdi:delete-outline"
|
||||||
title="Supprimer"
|
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')"
|
@click="emit('bulk-delete')"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="22" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
allSelected: boolean
|
allSelected: boolean
|
||||||
@@ -100,7 +111,12 @@ const props = defineProps<{
|
|||||||
priorities: TaskPriority[]
|
priorities: TaskPriority[]
|
||||||
efforts: TaskEffort[]
|
efforts: TaskEffort[]
|
||||||
groups: TaskGroup[]
|
groups: TaskGroup[]
|
||||||
}>()
|
selectedTasks?: Task[]
|
||||||
|
projects?: Project[]
|
||||||
|
}>(), {
|
||||||
|
selectedTasks: () => [],
|
||||||
|
projects: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggle-all'): void
|
(e: 'toggle-all'): void
|
||||||
@@ -109,23 +125,42 @@ const emit = defineEmits<{
|
|||||||
(e: 'bulk-delete'): void
|
(e: 'bulk-delete'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
const distinctProjectIds = computed(() => {
|
||||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
const ids = new Set<number>()
|
||||||
)
|
for (const t of props.selectedTasks) {
|
||||||
|
if (t.project) ids.add(t.project.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
|
||||||
|
|
||||||
|
const statusOptions = computed<{ label: string, value: number }[]>(() => {
|
||||||
|
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
|
||||||
|
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
|
||||||
|
const projectId = [...distinctProjectIds.value][0]
|
||||||
|
const project = props.projects.find(p => p.id === projectId)
|
||||||
|
if (project?.workflow?.statuses) {
|
||||||
|
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
|
||||||
|
return props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
})
|
||||||
|
|
||||||
const userOptions = computed(() =>
|
const userOptions = computed(() =>
|
||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const priorityOptions = computed(() =>
|
const priorityOptions = computed(() =>
|
||||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
props.priorities.map(p => ({ label: p.label, value: p.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const effortOptions = computed(() =>
|
const effortOptions = computed(() =>
|
||||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
props.efforts.map(e => ({ label: e.label, value: e.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupOptions = computed(() =>
|
const groupOptions = computed(() =>
|
||||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,16 +29,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="shrink-0 transition-colors"
|
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
: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()"
|
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||||
>
|
/>
|
||||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex items-center gap-1.5">
|
<div class="mt-2 flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
v-if="showStatusBadge && task.status"
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: task.status.color }"
|
||||||
|
>
|
||||||
|
{{ task.status.label }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.priority"
|
v-if="task.priority"
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
@@ -77,11 +85,17 @@
|
|||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
size="14"
|
size="14"
|
||||||
/>
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="task.collaborators?.length"
|
||||||
|
name="mdi:account-group"
|
||||||
|
class="ml-auto h-4 w-4 text-neutral-400"
|
||||||
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
:user="task.assignee"
|
:user="task.assignee"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="ml-auto"
|
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
@@ -99,8 +113,10 @@ import type { Task } from '~/services/dto/task'
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
showProjectColor?: boolean
|
showProjectColor?: boolean
|
||||||
|
showStatusBadge?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
showProjectColor: false,
|
showProjectColor: false,
|
||||||
|
showStatusBadge: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -32,14 +32,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete button -->
|
<!-- Delete button -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
type="button"
|
icon="heroicons:x-mark"
|
||||||
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"
|
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)"
|
@click.stop="$emit('delete', doc)"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,28 +12,34 @@
|
|||||||
ref="overlayRef"
|
ref="overlayRef"
|
||||||
>
|
>
|
||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
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')"
|
@click="$emit('close')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Navigation arrows -->
|
<!-- Navigation arrows -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="hasPrev"
|
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')"
|
@click="$emit('prev')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
<MalioButtonIcon
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="hasNext"
|
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')"
|
@click="$emit('next')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -10,16 +10,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -38,24 +38,22 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="activeTab === 'branches'"
|
v-if="activeTab === 'branches'"
|
||||||
type="button"
|
icon="mdi:content-copy"
|
||||||
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"
|
:aria-label="$t('gitea.branch.copy')"
|
||||||
:title="$t('gitea.branch.copy')"
|
variant="ghost"
|
||||||
|
icon-size="14"
|
||||||
@click="handleCopy"
|
@click="handleCopy"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:content-copy" size="14" />
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="activeTab === 'branches'"
|
v-if="activeTab === 'branches'"
|
||||||
type="button"
|
icon-name="mdi:plus"
|
||||||
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-position="left"
|
||||||
|
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||||
|
:label="$t('gitea.branch.create')"
|
||||||
@click="showCreateForm = !showCreateForm"
|
@click="showCreateForm = !showCreateForm"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
|
||||||
{{ $t('gitea.branch.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,14 +77,12 @@
|
|||||||
:label="$t('gitea.branch.baseBranch')"
|
:label="$t('gitea.branch.baseBranch')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||||
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"
|
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
@click="handleCreate"
|
@click="handleCreate"
|
||||||
>
|
/>
|
||||||
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||||
{{ branchPreview }}
|
{{ branchPreview }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||||
@blur="touched.title = true"
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="3"
|
min-height="120px"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
@@ -25,34 +25,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canArchive"
|
v-if="canArchive"
|
||||||
type="button"
|
variant="secondary"
|
||||||
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"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchive"
|
@click="handleArchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canUnarchive"
|
v-if="canUnarchive"
|
||||||
type="button"
|
variant="secondary"
|
||||||
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"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleUnarchive"
|
@click="handleUnarchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -78,24 +78,33 @@
|
|||||||
|
|
||||||
<!-- Right: timer top, avatar bottom -->
|
<!-- Right: timer top, avatar bottom -->
|
||||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="shrink-0 transition-colors"
|
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
: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)"
|
@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"
|
|
||||||
size="xs"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<div class="flex items-center gap-1">
|
||||||
v-else
|
<Icon
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
v-if="task.collaborators?.length"
|
||||||
>
|
name="mdi:account-group"
|
||||||
<Icon name="mdi:account-outline" size="14" />
|
class="h-4 w-4 text-neutral-400"
|
||||||
</span>
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="task.assignee"
|
||||||
|
:user="task.assignee"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:account-outline" size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -27,13 +27,13 @@
|
|||||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
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"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client ticket link -->
|
<!-- Client ticket link -->
|
||||||
@@ -170,15 +170,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Collaborators -->
|
||||||
|
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||||
|
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="user in collaboratorOptions"
|
||||||
|
:key="user.value"
|
||||||
|
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||||
|
:class="form.collaboratorIds.includes(user.value)
|
||||||
|
? 'bg-primary-500 text-white shadow-sm'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="hidden"
|
||||||
|
:value="user.value"
|
||||||
|
:checked="form.collaboratorIds.includes(user.value)"
|
||||||
|
@change="toggleCollaborator(user.value)"
|
||||||
|
/>
|
||||||
|
{{ user.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,48 +438,43 @@
|
|||||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||||
>
|
>
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="danger"
|
||||||
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"
|
label="Supprimer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="confirmDeleteOpen = true"
|
@click="confirmDeleteOpen = true"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canArchive"
|
v-if="canArchive"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
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"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchive"
|
@click="handleArchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canUnarchive"
|
v-if="canUnarchive"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
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"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleUnarchive"
|
@click="handleUnarchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
<MalioButton
|
||||||
</button>
|
variant="tertiary"
|
||||||
<button
|
label="Annuler"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -549,6 +565,7 @@ const form = reactive({
|
|||||||
effortId: null as number | null,
|
effortId: null as number | null,
|
||||||
priorityId: null as number | null,
|
priorityId: null as number | null,
|
||||||
assigneeId: null as number | null,
|
assigneeId: null as number | null,
|
||||||
|
collaboratorIds: [] as number[],
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
clientTicketId: null as number | null,
|
clientTicketId: null as number | null,
|
||||||
@@ -591,6 +608,18 @@ const userOptions = computed(() =>
|
|||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collaboratorOptions = computed(() =>
|
||||||
|
props.users
|
||||||
|
.filter(u => u.id !== form.assigneeId)
|
||||||
|
.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||||
|
if (newAssigneeId) {
|
||||||
|
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
let filtered = props.groups.filter(g => !g.archived)
|
let filtered = props.groups.filter(g => !g.archived)
|
||||||
if (showProjectSelect.value && form.projectId) {
|
if (showProjectSelect.value && form.projectId) {
|
||||||
@@ -629,6 +658,12 @@ function toggleTag(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCollaborator(userId: number) {
|
||||||
|
const idx = form.collaboratorIds.indexOf(userId)
|
||||||
|
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||||
|
else form.collaboratorIds.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
const weekDays = computed(() => [
|
const weekDays = computed(() => [
|
||||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||||
@@ -653,6 +688,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = task.effort?.id ?? null
|
form.effortId = task.effort?.id ?? null
|
||||||
form.priorityId = task.priority?.id ?? null
|
form.priorityId = task.priority?.id ?? null
|
||||||
form.assigneeId = task.assignee?.id ?? null
|
form.assigneeId = task.assignee?.id ?? null
|
||||||
|
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
form.clientTicketId = task.clientTicket?.id ?? null
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
@@ -699,6 +735,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = null
|
form.effortId = null
|
||||||
form.priorityId = null
|
form.priorityId = null
|
||||||
form.assigneeId = null
|
form.assigneeId = null
|
||||||
|
form.collaboratorIds = []
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
form.clientTicketId = null
|
form.clientTicketId = null
|
||||||
@@ -911,6 +948,7 @@ async function handleSubmit() {
|
|||||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||||
|
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${resolvedProjectId.value}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -13,16 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppDrawer 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"
|
|
||||||
label="Libellé"
|
|
||||||
input-class="w-full"
|
|
||||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
|
||||||
@blur="touched.label = true"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.position"
|
|
||||||
label="Position"
|
|
||||||
input-class="w-full"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<div class="mt-4">
|
|
||||||
<ColorPicker v-model="form.color" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="isFinal"
|
|
||||||
v-model="form.isFinal"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<label for="isFinal" class="text-sm font-medium text-neutral-700">
|
|
||||||
{{ $t('archive.statusFinal') }}
|
|
||||||
</label>
|
|
||||||
</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"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
|
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
item: TaskStatus | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.item)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
label: '',
|
|
||||||
position: '0',
|
|
||||||
color: '#222783',
|
|
||||||
isFinal: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const touched = reactive({
|
|
||||||
label: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
if (props.item) {
|
|
||||||
form.label = props.item.label ?? ''
|
|
||||||
form.position = String(props.item.position ?? 0)
|
|
||||||
form.color = props.item.color ?? '#222783'
|
|
||||||
form.isFinal = props.item.isFinal ?? false
|
|
||||||
} else {
|
|
||||||
form.label = ''
|
|
||||||
form.position = '0'
|
|
||||||
form.color = '#222783'
|
|
||||||
form.isFinal = false
|
|
||||||
}
|
|
||||||
touched.label = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { create, update } = useTaskStatusService()
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
touched.label = true
|
|
||||||
if (!form.label.trim()) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const payload: TaskStatusWrite = {
|
|
||||||
label: form.label.trim(),
|
|
||||||
position: Number(form.position),
|
|
||||||
color: form.color,
|
|
||||||
isFinal: form.isFinal,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value && props.item) {
|
|
||||||
await update(props.item.id, payload)
|
|
||||||
} else {
|
|
||||||
await create(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -13,16 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||||
@@ -11,14 +11,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<MalioInputRichText
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
v-model="form.description"
|
||||||
<textarea
|
label="Description"
|
||||||
v-model="form.description"
|
min-height="120px"
|
||||||
rows="3"
|
/>
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||||
@@ -97,33 +94,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="danger"
|
||||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
|
label="Supprimer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
label="Dupliquer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="onDuplicate"
|
@click="onDuplicate"
|
||||||
>
|
/>
|
||||||
Dupliquer
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-4"
|
||||||
type="submit"
|
@click="onSubmit"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
/>
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,19 +54,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete action -->
|
<!-- Delete action -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
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"
|
icon="mdi:delete-outline"
|
||||||
:title="$t('common.delete')"
|
: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)"
|
@click.stop="emit('deleteEntry', entry)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
|
|||||||
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal 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>
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
<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>
|
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="24" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
<template>
|
<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]">
|
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||||
<div class="flex h-full items-center justify-between">
|
<div class="flex h-full items-center justify-between">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
icon="mdi:menu"
|
||||||
|
aria-label="Menu"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||||
@click="ui.openMobileSidebar()"
|
@click="ui.openMobileSidebar()"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:menu" size="24" />
|
|
||||||
</button>
|
|
||||||
<div class="hidden items-center gap-2 lg:flex">
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
|
|
||||||
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
|
||||||
@click="toggleTitle"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:swap-horizontal" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:help-circle-outline"
|
||||||
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
|
aria-label="Centre d'aide"
|
||||||
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
|
@click="navigateTo('/help')"
|
||||||
|
/>
|
||||||
|
<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()"
|
@click="ui.toggleDarkMode()"
|
||||||
>
|
/>
|
||||||
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
|
|
||||||
</button>
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div class="group relative flex gap-2 sm:gap-4">
|
<div class="group relative flex gap-2 sm:gap-4">
|
||||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||||
@@ -64,13 +66,6 @@ defineProps<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
|
||||||
|
|
||||||
function toggleTitle() {
|
|
||||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
|
||||||
localStorage.setItem('appTitle', appTitle.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
|
|||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('projects.deleteConfirmMessage') }}
|
{{ $t('projects.deleteConfirmMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
:label="$t('common.delete')"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
{{ $t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport v-if="modelValue" to="body">
|
|
||||||
<Transition name="modal" appear>
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
|
||||||
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="targetStatusId"
|
|
||||||
:options="targetOptions"
|
|
||||||
:label="$t('taskStatuses.moveTo')"
|
|
||||||
:empty-option-label="$t('taskStatuses.backlog')"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
@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"
|
|
||||||
:disabled="isProcessing"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
{{ $t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
statusLabel: string
|
|
||||||
taskCount: number
|
|
||||||
availableStatuses: TaskStatus[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm', targetStatusId: number | null): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const targetStatusId = ref<number | null>(null)
|
|
||||||
const isProcessing = ref(false)
|
|
||||||
|
|
||||||
const targetOptions = computed(() =>
|
|
||||||
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
targetStatusId.value = null
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
isProcessing.value = true
|
|
||||||
emit('confirm', targetStatusId.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('tasks.deleteConfirmMessage') }}
|
{{ $t('tasks.deleteConfirmMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,13 +35,15 @@
|
|||||||
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<slot name="actions" :item="item" />
|
<slot name="actions" :item="item" />
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="deletable"
|
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)"
|
@click.stop="$emit('delete', item)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="md-preview" appear>
|
||||||
|
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||||
|
style="max-height: min(80vh, 700px)"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="overflow-y-auto px-6 py-4">
|
||||||
|
<div
|
||||||
|
v-if="content"
|
||||||
|
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-slate-400">
|
||||||
|
Aucune description
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked.parse(props.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.md-preview-enter-active,
|
||||||
|
.md-preview-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-preview-enter-from,
|
||||||
|
.md-preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,21 +18,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="emit('cancel')"
|
@click="emit('cancel')"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
:label="$t('common.confirm')"
|
||||||
<button
|
button-class="w-auto px-4"
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:disabled="cropping"
|
:disabled="cropping"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
/>
|
||||||
{{ $t('common.confirm') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -8,12 +8,11 @@
|
|||||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||||
@blur="touched.username = true"
|
@blur="touched.username = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
label="Mot de passe"
|
label="Mot de passe"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
|
||||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||||
@blur="touched.password = true"
|
@blur="touched.password = true"
|
||||||
/>
|
/>
|
||||||
@@ -70,16 +69,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
27
frontend/content/help/01-getting-started.md
Normal file
27
frontend/content/help/01-getting-started.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Bienvenue dans Lesstime
|
||||||
|
|
||||||
|
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||||
|
|
||||||
|
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||||
|
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||||
|
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||||
|
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||||
|
|
||||||
|
## Comprendre les rôles
|
||||||
|
|
||||||
|
| Rôle | Accès |
|
||||||
|
|---|---|
|
||||||
|
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||||
|
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||||
|
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||||
|
|
||||||
|
## Vues principales
|
||||||
|
|
||||||
|
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||||
|
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||||
|
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||||
|
- **Time tracking** : timer, time entries, vue mois
|
||||||
|
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||||
|
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||||
|
|
||||||
|
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||||
58
frontend/content/help/02-projects-workflows.md
Normal file
58
frontend/content/help/02-projects-workflows.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Projets & Workflows
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un projet ?
|
||||||
|
|
||||||
|
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||||
|
|
||||||
|
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||||
|
- Un **client** optionnel (ou interne si null)
|
||||||
|
- Une **couleur** d'identification
|
||||||
|
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un workflow ?
|
||||||
|
|
||||||
|
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||||
|
|
||||||
|
### Exemple
|
||||||
|
|
||||||
|
| Workflow | Statuts |
|
||||||
|
|---|---|
|
||||||
|
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||||
|
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||||
|
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||||
|
|
||||||
|
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||||
|
|
||||||
|
## Les 5 catégories canoniques
|
||||||
|
|
||||||
|
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||||
|
|
||||||
|
| Catégorie | Description |
|
||||||
|
|---|---|
|
||||||
|
| `todo` | À faire — pas encore commencé |
|
||||||
|
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||||
|
| `blocked` | Bloqué — attente d'une dépendance |
|
||||||
|
| `review` | En validation — relecture, PR, QA |
|
||||||
|
| `done` | Terminé — close |
|
||||||
|
|
||||||
|
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||||
|
|
||||||
|
## Changer le workflow d'un projet
|
||||||
|
|
||||||
|
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||||
|
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||||
|
3. Sélectionner le workflow cible
|
||||||
|
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||||
|
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||||
|
|
||||||
|
### Règles du mapping
|
||||||
|
|
||||||
|
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||||
|
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||||
|
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||||
|
|
||||||
|
## Supprimer un workflow
|
||||||
|
|
||||||
|
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||||
|
|
||||||
|
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||||
60
frontend/content/help/03-my-tasks.md
Normal file
60
frontend/content/help/03-my-tasks.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Mes tâches & Dashboard
|
||||||
|
|
||||||
|
## Vue *Mes tâches*
|
||||||
|
|
||||||
|
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||||
|
|
||||||
|
### Deux modes d'affichage
|
||||||
|
|
||||||
|
#### 1. Kanban (par défaut)
|
||||||
|
|
||||||
|
Regroupé par les **5 catégories canoniques** :
|
||||||
|
|
||||||
|
```
|
||||||
|
À faire → En cours → Bloqué → En validation → Terminé
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque card affiche :
|
||||||
|
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||||
|
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||||
|
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||||
|
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||||
|
|
||||||
|
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||||
|
|
||||||
|
#### 2. Liste
|
||||||
|
|
||||||
|
Vue tableau triable, avec **bulk actions** :
|
||||||
|
- Cocher plusieurs tâches → barre d'actions en haut
|
||||||
|
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||||
|
- Supprimer en lot
|
||||||
|
|
||||||
|
### Filtres disponibles
|
||||||
|
|
||||||
|
| Filtre | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Projet** | Restreint à un projet précis |
|
||||||
|
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||||
|
| **Tag** | Tags globaux |
|
||||||
|
| **Priorité / Effort** | |
|
||||||
|
| **Assigné** | Par défaut : toi-même |
|
||||||
|
|
||||||
|
### Tri (vue liste uniquement)
|
||||||
|
|
||||||
|
- Par **deadline** (les plus proches en premier)
|
||||||
|
- Par **scheduled start** (planification calendrier)
|
||||||
|
|
||||||
|
## Vue *Backlog*
|
||||||
|
|
||||||
|
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Le **dashboard** (page d'accueil après login) affiche :
|
||||||
|
|
||||||
|
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||||
|
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||||
|
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||||
|
- ⏱ **Timer actif** s'il y en a un
|
||||||
|
|
||||||
|
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||||
59
frontend/content/help/04-time-tracking.md
Normal file
59
frontend/content/help/04-time-tracking.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Time tracking
|
||||||
|
|
||||||
|
## Le timer
|
||||||
|
|
||||||
|
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||||
|
|
||||||
|
### Démarrer un timer
|
||||||
|
|
||||||
|
Trois façons :
|
||||||
|
|
||||||
|
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||||
|
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||||
|
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||||
|
|
||||||
|
### Arrêter
|
||||||
|
|
||||||
|
- Clique sur ⏹ sur la card de la tâche en cours
|
||||||
|
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||||
|
|
||||||
|
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||||
|
|
||||||
|
## Time entries
|
||||||
|
|
||||||
|
Chaque entrée a :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||||
|
| **Projet** | Obligatoire |
|
||||||
|
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||||
|
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||||
|
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||||
|
| **User** | Qui a fait le travail |
|
||||||
|
|
||||||
|
### Vue *Time tracking*
|
||||||
|
|
||||||
|
Disponible en deux modes :
|
||||||
|
|
||||||
|
- **Vue semaine** : ligne par ligne, par jour
|
||||||
|
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
|
||||||
|
- **Projet** (server-side)
|
||||||
|
- **Tag** (server-side)
|
||||||
|
- **User** (admin uniquement)
|
||||||
|
- **Période** (date début / date fin)
|
||||||
|
|
||||||
|
## Édition
|
||||||
|
|
||||||
|
- Clique sur une time entry → drawer d'édition
|
||||||
|
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||||
|
- La suppression est libre — pense à exporter avant si nécessaire
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||||
|
|
||||||
|
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||||
62
frontend/content/help/05-tasks-detail.md
Normal file
62
frontend/content/help/05-tasks-detail.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Détail d'une tâche
|
||||||
|
|
||||||
|
## Champs principaux
|
||||||
|
|
||||||
|
| Champ | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||||
|
| **Titre** | Obligatoire |
|
||||||
|
| **Description** | Markdown supporté (preview disponible) |
|
||||||
|
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||||
|
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||||
|
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||||
|
| **Assigné** | Un seul user responsable |
|
||||||
|
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||||
|
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||||
|
| **Tags** | Globaux, plusieurs par tâche |
|
||||||
|
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||||
|
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||||
|
|
||||||
|
## Récurrence
|
||||||
|
|
||||||
|
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||||
|
|
||||||
|
- **Type** : quotidien, hebdomadaire, mensuel
|
||||||
|
- **Intervalle** : tous les N jours/semaines/mois
|
||||||
|
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||||
|
|
||||||
|
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||||
|
|
||||||
|
## Sync calendrier
|
||||||
|
|
||||||
|
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||||
|
|
||||||
|
Icônes correspondantes :
|
||||||
|
- 🟢 `mdi:calendar-check` → sync OK
|
||||||
|
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||||
|
|
||||||
|
- Drag & drop dans la tâche pour uploader
|
||||||
|
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||||
|
- Téléchargement via lien dédié
|
||||||
|
|
||||||
|
## Liaison Gitea (si configuré)
|
||||||
|
|
||||||
|
Si le projet a un repo Gitea lié, tu peux :
|
||||||
|
|
||||||
|
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||||
|
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||||
|
- **Voir les PRs** liées (état CI inclus)
|
||||||
|
|
||||||
|
## Liaison ticket client
|
||||||
|
|
||||||
|
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Commentaires & notifications
|
||||||
|
|
||||||
|
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||||
|
- Les @mentions notifient l'utilisateur cité
|
||||||
|
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||||
43
frontend/content/help/06-client-portal.md
Normal file
43
frontend/content/help/06-client-portal.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Portal client
|
||||||
|
|
||||||
|
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||||
|
|
||||||
|
## Ce que voit un client
|
||||||
|
|
||||||
|
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||||
|
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||||
|
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||||
|
|
||||||
|
## Soumettre un ticket
|
||||||
|
|
||||||
|
Depuis `/portal/projects/<id>/new-ticket` :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Type** | `bug` / `improvement` / `other` |
|
||||||
|
| **Titre** | Court et descriptif |
|
||||||
|
| **Description** | Détails — markdown supporté |
|
||||||
|
| **URL** | Optionnel — page où le problème se manifeste |
|
||||||
|
|
||||||
|
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Statuts d'un ticket
|
||||||
|
|
||||||
|
| Statut | Visible côté client | Signification |
|
||||||
|
|---|---|---|
|
||||||
|
| `new` | Oui | Reçu, pas encore traité |
|
||||||
|
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||||
|
| `done` | Oui | Résolu et clôturé |
|
||||||
|
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||||
|
|
||||||
|
Le `statusComment` est visible par le client quand fourni.
|
||||||
|
|
||||||
|
## Côté équipe interne
|
||||||
|
|
||||||
|
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||||
|
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||||
|
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||||
66
frontend/content/help/07-admin.md
Normal file
66
frontend/content/help/07-admin.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Administration
|
||||||
|
|
||||||
|
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||||
|
|
||||||
|
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||||
|
|
||||||
|
## Onglet *Clients*
|
||||||
|
|
||||||
|
- Liste des clients (entreprise / organisation)
|
||||||
|
- Champs : nom, email, téléphone, adresse
|
||||||
|
- Lier un client à des projets
|
||||||
|
|
||||||
|
## Onglet *Workflows*
|
||||||
|
|
||||||
|
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||||
|
|
||||||
|
- Lister les workflows existants
|
||||||
|
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||||
|
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||||
|
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||||
|
|
||||||
|
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||||
|
|
||||||
|
## Onglet *Efforts*
|
||||||
|
|
||||||
|
- Tailles d'effort (S, M, L, XL, XXL)
|
||||||
|
- Globales (partagées entre tous les projets)
|
||||||
|
|
||||||
|
## Onglet *Priorités*
|
||||||
|
|
||||||
|
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||||
|
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||||
|
|
||||||
|
## Onglet *Tags*
|
||||||
|
|
||||||
|
- Tags globaux (tâches **et** time entries)
|
||||||
|
- Couleur personnalisable
|
||||||
|
- Pas de hiérarchie (flat list)
|
||||||
|
|
||||||
|
## Onglet *Utilisateurs*
|
||||||
|
|
||||||
|
- Créer / éditer / désactiver
|
||||||
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||||
|
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||||
|
- Reset password depuis l'admin
|
||||||
|
|
||||||
|
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||||
|
|
||||||
|
## Onglet *Gitea*
|
||||||
|
|
||||||
|
- URL serveur + token API
|
||||||
|
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||||
|
- Active les fonctionnalités branches / PRs sur les tâches
|
||||||
|
|
||||||
|
## Onglet *BookStack*
|
||||||
|
|
||||||
|
- URL + token API
|
||||||
|
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||||
|
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||||
|
|
||||||
|
## Onglet *Zimbra*
|
||||||
|
|
||||||
|
- URL serveur + credentials (chiffrés via libsodium)
|
||||||
|
- Configure le calendrier CalDav par défaut
|
||||||
|
- Test de connexion intégré
|
||||||
|
- Active la **sync calendrier** sur les tâches planifiées
|
||||||
66
frontend/content/help/08-integrations.md
Normal file
66
frontend/content/help/08-integrations.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Intégrations
|
||||||
|
|
||||||
|
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||||
|
|
||||||
|
## 🌳 Gitea
|
||||||
|
|
||||||
|
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Gitea** : URL serveur + token API
|
||||||
|
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche, le panneau Gitea propose :
|
||||||
|
|
||||||
|
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||||
|
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||||
|
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||||
|
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||||
|
|
||||||
|
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||||
|
|
||||||
|
## 📚 BookStack
|
||||||
|
|
||||||
|
Lien tâche → documentation.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||||
|
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||||
|
- Sélectionner la page dans le shelf du projet
|
||||||
|
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||||
|
|
||||||
|
## 📅 Zimbra (CalDav)
|
||||||
|
|
||||||
|
Sync calendrier pour les tâches planifiées.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Zimbra** :
|
||||||
|
- URL serveur (ex: `https://mail.ovh.com`)
|
||||||
|
- Username (ex: `lesstime@ovh.fr`)
|
||||||
|
- Password (chiffré côté serveur)
|
||||||
|
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||||
|
- **Test de connexion** intégré
|
||||||
|
2. Active la config (toggle `enabled`)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche avec **scheduled start + end** :
|
||||||
|
|
||||||
|
1. Cocher **Sync calendrier**
|
||||||
|
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||||
|
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||||
|
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||||
|
|
||||||
|
### Limites
|
||||||
|
|
||||||
|
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||||
|
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||||
97
frontend/content/help/09-mcp-api.md
Normal file
97
frontend/content/help/09-mcp-api.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Token MCP & API
|
||||||
|
|
||||||
|
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||||
|
|
||||||
|
## Générer ton token
|
||||||
|
|
||||||
|
1. Va sur **Profil** (avatar → Profil)
|
||||||
|
2. Section **Token MCP** → **Générer un token**
|
||||||
|
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||||
|
|
||||||
|
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||||
|
|
||||||
|
## Configurer Claude Code
|
||||||
|
|
||||||
|
Dans `.mcp.json` (à la racine de ton projet) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://ton-instance-lesstime/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour une instance locale :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime-local": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools disponibles (27 au total)
|
||||||
|
|
||||||
|
### Projets
|
||||||
|
|
||||||
|
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||||
|
|
||||||
|
### Tâches
|
||||||
|
|
||||||
|
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||||
|
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||||
|
|
||||||
|
### Métadonnées
|
||||||
|
|
||||||
|
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||||
|
- `list-priorities`, `list-efforts`, `list-tags`
|
||||||
|
|
||||||
|
### Workflows ⭐ Nouveau
|
||||||
|
|
||||||
|
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||||
|
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||||
|
|
||||||
|
### Time tracking
|
||||||
|
|
||||||
|
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||||
|
|
||||||
|
### Récurrence
|
||||||
|
|
||||||
|
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||||
|
|
||||||
|
### Groupes / Users / Clients
|
||||||
|
|
||||||
|
- `list-groups`, `create-group`, `update-group`
|
||||||
|
- `list-users`, `list-clients`
|
||||||
|
|
||||||
|
## Règles importantes
|
||||||
|
|
||||||
|
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||||
|
|
||||||
|
## Exemples de prompts
|
||||||
|
|
||||||
|
```
|
||||||
|
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||||
|
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Liste mes tâches en cours dans le projet CRM"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
L'agent appelle les bons tools tout seul si la description est claire.
|
||||||
@@ -56,6 +56,37 @@
|
|||||||
"moveTo": "Déplacer vers",
|
"moveTo": "Déplacer vers",
|
||||||
"backlog": "Backlog (sans statut)"
|
"backlog": "Backlog (sans statut)"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Workflows",
|
||||||
|
"addWorkflow": "Ajouter un workflow",
|
||||||
|
"editWorkflow": "Modifier le workflow",
|
||||||
|
"name": "Nom",
|
||||||
|
"isDefault": "Workflow par défaut",
|
||||||
|
"statuses": "Statuts",
|
||||||
|
"addStatus": "Ajouter un statut",
|
||||||
|
"category": "Catégorie",
|
||||||
|
"created": "Workflow créé",
|
||||||
|
"updated": "Workflow mis à jour",
|
||||||
|
"deleted": "Workflow supprimé",
|
||||||
|
"switched": "Workflow du projet changé",
|
||||||
|
"switchTitle": "Changer de workflow",
|
||||||
|
"switchTargetLabel": "Nouveau workflow",
|
||||||
|
"switchMappingTitle": "Mapping des statuts",
|
||||||
|
"switchSourceCol": "Statut actuel",
|
||||||
|
"switchTargetCol": "Statut cible",
|
||||||
|
"switchTaskCountCol": "Tâches",
|
||||||
|
"switchToBacklog": "Mapper vers le backlog",
|
||||||
|
"switchConfirm": "Confirmer la migration",
|
||||||
|
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
|
||||||
|
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
|
||||||
|
"categories": {
|
||||||
|
"todo": "À faire",
|
||||||
|
"in_progress": "En cours",
|
||||||
|
"blocked": "Bloqué",
|
||||||
|
"review": "En validation",
|
||||||
|
"done": "Terminé"
|
||||||
|
}
|
||||||
|
},
|
||||||
"taskEfforts": {
|
"taskEfforts": {
|
||||||
"created": "Effort créé avec succès.",
|
"created": "Effort créé avec succès.",
|
||||||
"updated": "Effort mis à jour avec succès.",
|
"updated": "Effort mis à jour avec succès.",
|
||||||
@@ -162,7 +193,21 @@
|
|||||||
"noEntries": "Aucune activité pour cette période",
|
"noEntries": "Aucune activité pour cette période",
|
||||||
"addEntry": "Ajouter une Activité",
|
"addEntry": "Ajouter une Activité",
|
||||||
"editEntry": "Modifier un temps",
|
"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": {
|
"archive": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
@@ -379,7 +424,21 @@
|
|||||||
"title": "Mon profil",
|
"title": "Mon profil",
|
||||||
"changeAvatar": "Changer l'avatar",
|
"changeAvatar": "Changer l'avatar",
|
||||||
"removeAvatar": "Supprimer l'avatar",
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
"cropAvatar": "Recadrer l'avatar"
|
"cropAvatar": "Recadrer l'avatar",
|
||||||
|
"apiToken": {
|
||||||
|
"title": "Token API MCP",
|
||||||
|
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
|
||||||
|
"label": "Token",
|
||||||
|
"empty": "Aucun token généré pour le moment.",
|
||||||
|
"generate": "Générer un token",
|
||||||
|
"regenerate": "Régénérer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Token copié dans le presse-papiers.",
|
||||||
|
"copyFailed": "Impossible de copier le token.",
|
||||||
|
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
|
||||||
|
"confirmTitle": "Régénérer le token MCP ?",
|
||||||
|
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bookstack": {
|
"bookstack": {
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['markdown-it-task-lists'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
830
frontend/package-lock.json
generated
830
frontend/package-lock.json
generated
@@ -7,13 +7,15 @@
|
|||||||
"name": "nuxt-app",
|
"name": "nuxt-app",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.1.0",
|
"@malio/layer-ui": "^1.4.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -1116,6 +1118,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
@@ -2203,19 +2206,545 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.1.0",
|
"version": "1.4.8",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.1.0/layer-ui-1.1.0.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.8/layer-ui-1.4.8.tgz",
|
||||||
"integrity": "sha512-mc+kOK+EDfo6ZZcE0/FaVnvDyIDJrigkgOzvL8rxnpljXEiRlKj5673e5e6ZIoOyKFqktzbJXzFr4V6UBD0wPg==",
|
"integrity": "sha512-ABQmfMqJqKGGnx6kf5KK/XVuKAPWSpRHmLpS9XMg6pUH8kww8o3JoywlrlFkk9xA30zNFaehAtzV7S19E4JTlg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"@tiptap/extension-color": "^3.22.5",
|
||||||
|
"@tiptap/extension-highlight": "^3.22.5",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.5",
|
||||||
|
"@tiptap/extension-text-style": "^3.22.5",
|
||||||
|
"@tiptap/pm": "^3.22.5",
|
||||||
|
"@tiptap/starter-kit": "^3.22.5",
|
||||||
|
"@tiptap/vue-3": "^3.22.5",
|
||||||
"maska": "^3.2.0",
|
"maska": "^3.2.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tiptap-markdown": "^0.9.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"nuxt": "^4.0.0"
|
"nuxt": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/core": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-color": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4aTygOUlTFBYCvJy67SeKVdXCQw7du3Rj+N5ZutVnDnrpfzUBWsO7f+I+iDS8eMQFbWxVFLlWxGMcTbjtk1a+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-text-style": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-highlight": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-byWruAOKcqRN0OuzVSKqLLrced3M9AZaR2pD1BV3aUZHzMzeBjLBfByh8s4lExH2Z547xQUdHHnUflBQ572I5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-text-style": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.22.5",
|
||||||
|
"@tiptap/extension-blockquote": "^3.22.5",
|
||||||
|
"@tiptap/extension-bold": "^3.22.5",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-code": "^3.22.5",
|
||||||
|
"@tiptap/extension-code-block": "^3.22.5",
|
||||||
|
"@tiptap/extension-document": "^3.22.5",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-hard-break": "^3.22.5",
|
||||||
|
"@tiptap/extension-heading": "^3.22.5",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.22.5",
|
||||||
|
"@tiptap/extension-italic": "^3.22.5",
|
||||||
|
"@tiptap/extension-link": "^3.22.5",
|
||||||
|
"@tiptap/extension-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-item": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.22.5",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-paragraph": "^3.22.5",
|
||||||
|
"@tiptap/extension-strike": "^3.22.5",
|
||||||
|
"@tiptap/extension-text": "^3.22.5",
|
||||||
|
"@tiptap/extension-underline": "^3.22.5",
|
||||||
|
"@tiptap/extensions": "^3.22.5",
|
||||||
|
"@tiptap/pm": "^3.22.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@tiptap/vue-3": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-xwSXPwDjauIVktMXBMaNaSgFyq3O1sXcX1vWyHyyCFlq4+8ekq4uXbjkD6y6IhZyr/AQoRYnjgosus+apGyGuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.22.5",
|
||||||
|
"@tiptap/extension-floating-menu": "^3.22.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5",
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@types/linkify-it": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@types/markdown-it": {
|
||||||
|
"version": "13.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
|
||||||
|
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^3",
|
||||||
|
"@types/mdurl": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/@types/mdurl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@malio/layer-ui/node_modules/tiptap-markdown": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"example"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^13.0.7",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
|
"prosemirror-markdown": "^1.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/node-pre-gyp": {
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
|
||||||
@@ -5295,6 +5824,31 @@
|
|||||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/typography": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -5323,6 +5877,28 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
@@ -9498,6 +10074,21 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/listhen": {
|
"node_modules/listhen": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
||||||
@@ -9679,6 +10270,53 @@
|
|||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it-task-lists": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||||
|
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/maska": {
|
"node_modules/maska": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
||||||
@@ -9700,6 +10338,12 @@
|
|||||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -10466,6 +11110,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/oxc-minify": {
|
"node_modules/oxc-minify": {
|
||||||
"version": "0.112.0",
|
"version": "0.112.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.112.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.112.0.tgz",
|
||||||
@@ -11492,6 +12142,149 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||||
|
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -11501,6 +12294,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quansync": {
|
"node_modules/quansync": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
@@ -11928,6 +12730,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rou3": {
|
"node_modules/rou3": {
|
||||||
"version": "0.7.12",
|
"version": "0.7.12",
|
||||||
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
|
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
|
||||||
@@ -13058,6 +13866,12 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ufo": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||||
@@ -13969,6 +14783,12 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.1.0",
|
"@malio/layer-ui": "^1.4.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
@@ -40,7 +40,7 @@ useHead({ title: 'Administration' })
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'clients', label: 'Clients' },
|
{ key: 'clients', label: 'Clients' },
|
||||||
{ key: 'statuses', label: 'Statuts' },
|
{ key: 'workflows', label: 'Workflows' },
|
||||||
{ key: 'efforts', label: 'Efforts' },
|
{ key: 'efforts', label: 'Efforts' },
|
||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
|
|||||||
168
frontend/pages/help.vue
Normal file
168
frontend/pages/help.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
useHead({ title: 'Aide' })
|
||||||
|
|
||||||
|
type Section = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
accent: string
|
||||||
|
roles: ('admin' | 'user' | 'client')[]
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||||
|
|
||||||
|
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||||
|
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||||
|
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||||
|
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||||
|
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||||
|
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||||
|
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||||
|
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||||
|
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||||
|
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = computed<Section[]>(() => {
|
||||||
|
return Object.entries(rawModules).map(([path, raw]) => {
|
||||||
|
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||||
|
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||||
|
return { id, ...meta, content: raw }
|
||||||
|
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||||
|
const roles = auth.user?.roles ?? []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||||
|
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||||
|
return 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleSections = computed(() =>
|
||||||
|
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||||
|
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||||
|
activeId.value = hash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeId, (id) => {
|
||||||
|
router.replace({ query: { ...route.query, section: id } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!activeSection.value) return ''
|
||||||
|
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
|
||||||
|
const prevSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||||
|
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||||
|
<Icon name="mdi:lifebuoy" size="20" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||||
|
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||||
|
<button
|
||||||
|
v-for="section in visibleSections"
|
||||||
|
:key="section.id"
|
||||||
|
type="button"
|
||||||
|
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||||
|
:class="activeId === section.id
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||||
|
@click="activeId = section.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||||
|
:class="section.accent"
|
||||||
|
>
|
||||||
|
<Icon :name="section.icon" size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||||
|
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||||
|
<!-- Hero header -->
|
||||||
|
<div
|
||||||
|
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||||
|
:class="activeSection.accent"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||||
|
<Icon :name="activeSection.icon" size="28" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown content -->
|
||||||
|
<article
|
||||||
|
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!prevSection"
|
||||||
|
@click="prevSection && (activeId = prevSection.id)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-left" size="18" />
|
||||||
|
<span>{{ prevSection?.title ?? '' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!nextSection"
|
||||||
|
@click="nextSection && (activeId = nextSection.id)"
|
||||||
|
>
|
||||||
|
<span>{{ nextSection?.title ?? '' }}</span>
|
||||||
|
<Icon name="mdi:arrow-right" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
|||||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Filtered data (client-side project filter) ──
|
// ── Filtered data (client-side project + user filter) ──
|
||||||
|
|
||||||
|
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||||
|
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
if (!selectedProjectId.value) return allTasks.value
|
let result = allTasks.value
|
||||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
if (selectedProjectId.value) {
|
||||||
|
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||||
|
}
|
||||||
|
if (selectedUserId.value) {
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const timeEntries = computed(() => {
|
const timeEntries = computed(() => {
|
||||||
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const myTasks = computed(() =>
|
const myTasks = computed(() =>
|
||||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
tasks.value.filter(t =>
|
||||||
|
t.assignee?.id === effectiveUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const myTasksDone = computed(() =>
|
const myTasksDone = computed(() =>
|
||||||
|
|||||||
@@ -17,26 +17,19 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<MalioInputPassword
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
v-model="password"
|
||||||
Mot de passe
|
label="Mot de passe"
|
||||||
</label>
|
autocomplete="current-password"
|
||||||
<input
|
input-class="w-full"
|
||||||
id="password"
|
/>
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<MalioButton
|
||||||
|
label="Se connecter"
|
||||||
|
button-class="w-full"
|
||||||
type="submit"
|
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"
|
:disabled="isSubmitting"
|
||||||
>
|
/>
|
||||||
Se connecter
|
|
||||||
</button>
|
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
|
|||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { StatusCategory } from '~/services/dto/workflow'
|
||||||
|
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
@@ -51,14 +53,16 @@ const selectedEffortId = ref<number | null>(null)
|
|||||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
const SORT_DEADLINE = 1
|
||||||
const sortBy = ref<SortOption>('default')
|
const SORT_SCHEDULED = 2
|
||||||
|
const sortById = ref<number | null>(null)
|
||||||
|
|
||||||
// View toggle
|
// View toggle
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
// Bulk selection
|
// Bulk selection
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
|
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
const taskModalOpen = ref(false)
|
const taskModalOpen = ref(false)
|
||||||
@@ -106,13 +110,16 @@ const assigneeOptions = computed(() =>
|
|||||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kanban helpers
|
const sortOptions = computed(() => [
|
||||||
const sortedStatuses = computed(() =>
|
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||||
)
|
])
|
||||||
|
|
||||||
function tasksByStatus(statusId: number): Task[] {
|
// Kanban helpers (grouped by canonical status category)
|
||||||
return tasks.value.filter(t => t.status?.id === statusId)
|
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
||||||
|
|
||||||
|
function tasksByCategory(category: StatusCategory): Task[] {
|
||||||
|
return tasks.value.filter(t => t.status?.category === category)
|
||||||
}
|
}
|
||||||
|
|
||||||
const backlogTasks = computed(() =>
|
const backlogTasks = computed(() =>
|
||||||
@@ -140,33 +147,43 @@ async function loadReferenceData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const params: Record<string, string | number | boolean | string[]> = {
|
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||||
archived: false,
|
archived: false,
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
|
||||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
|
||||||
}
|
|
||||||
if (selectedProjectId.value) {
|
if (selectedProjectId.value) {
|
||||||
params.project = `/api/projects/${selectedProjectId.value}`
|
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||||
}
|
}
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||||
}
|
}
|
||||||
if (selectedPriorityId.value) {
|
if (selectedPriorityId.value) {
|
||||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||||
}
|
}
|
||||||
if (selectedEffortId.value) {
|
if (selectedEffortId.value) {
|
||||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||||
}
|
}
|
||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||||
}
|
}
|
||||||
if (sortBy.value === 'deadline') {
|
if (sortById.value === SORT_DEADLINE) {
|
||||||
params['order[deadline]'] = 'asc'
|
baseParams['order[deadline]'] = 'asc'
|
||||||
} else if (sortBy.value === 'scheduledStart') {
|
} else if (sortById.value === SORT_SCHEDULED) {
|
||||||
params['order[scheduledStart]'] = 'asc'
|
baseParams['order[scheduledStart]'] = 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAssigneeId.value) {
|
||||||
|
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||||
|
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||||
|
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||||
|
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||||
|
])
|
||||||
|
const map = new Map<number, Task>()
|
||||||
|
for (const t of assigneeTasks) map.set(t.id, t)
|
||||||
|
for (const t of collabTasks) map.set(t.id, t)
|
||||||
|
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||||
|
} else {
|
||||||
|
tasks.value = await taskService.getFiltered(baseParams)
|
||||||
}
|
}
|
||||||
tasks.value = await taskService.getFiltered(params)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@@ -180,7 +197,7 @@ async function loadAll() {
|
|||||||
|
|
||||||
// Watch filters and sort to reload tasks
|
// Watch filters and sort to reload tasks
|
||||||
watch(
|
watch(
|
||||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||||
() => { loadTasks() },
|
() => { loadTasks() },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,44 +206,6 @@ watch(selectedProjectId, () => {
|
|||||||
selectedGroupId.value = null
|
selectedGroupId.value = null
|
||||||
}, { flush: 'sync' })
|
}, { flush: 'sync' })
|
||||||
|
|
||||||
// Drag & drop
|
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
|
||||||
const dragCounter = ref(0)
|
|
||||||
|
|
||||||
function onDragEnter(id: number) {
|
|
||||||
dragCounter.value++
|
|
||||||
dragOverStatusId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragLeave() {
|
|
||||||
dragCounter.value--
|
|
||||||
if (dragCounter.value === 0) {
|
|
||||||
dragOverStatusId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(event: DragEvent) {
|
|
||||||
dragCounter.value = 0
|
|
||||||
dragOverStatusId.value = null
|
|
||||||
return Number(event.dataTransfer!.getData('text/plain'))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
|
||||||
const taskId = onDrop(event)
|
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
|
||||||
if (!task || task.status?.id === status.id) return
|
|
||||||
task.status = status
|
|
||||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDropBacklog(event: DragEvent) {
|
|
||||||
const taskId = onDrop(event)
|
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
|
||||||
if (!task || !task.status) return
|
|
||||||
task.status = null
|
|
||||||
await taskService.update(taskId, { status: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
function openTaskCreate() {
|
function openTaskCreate() {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
@@ -324,15 +303,16 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3"
|
||||||
@click="openTaskCreate"
|
@click="openTaskCreate"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:plus" size="18" />
|
|
||||||
{{ $t('myTasks.createTask') }}
|
{{ $t('myTasks.createTask') }}
|
||||||
</button>
|
</MalioButton>
|
||||||
<button
|
<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'
|
:class="viewMode === 'list'
|
||||||
? 'border-primary-500 bg-primary-500 text-white'
|
? 'border-primary-500 bg-primary-500 text-white'
|
||||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||||
@@ -399,50 +379,41 @@ onMounted(async () => {
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5">
|
<MalioSelect
|
||||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
v-model="sortById"
|
||||||
<select
|
:options="sortOptions"
|
||||||
v-model="sortBy"
|
:label="$t('myTasks.sortBy')"
|
||||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
>
|
min-width="!w-40"
|
||||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
text-field="text-sm"
|
||||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
text-value="text-sm"
|
||||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kanban View -->
|
<!-- Kanban View — grouped by canonical category -->
|
||||||
<div v-if="viewMode === 'kanban'">
|
<div v-if="viewMode === 'kanban'">
|
||||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="status in sortedStatuses"
|
v-for="cat in CATEGORIES"
|
||||||
:key="status.id"
|
:key="cat"
|
||||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
||||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
|
||||||
@dragover.prevent
|
|
||||||
@dragenter.prevent="onDragEnter(status.id)"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop.prevent="onDropStatus($event, status)"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
||||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||||
:style="{ backgroundColor: status.color }"
|
|
||||||
>
|
|
||||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<TaskCard
|
<TaskCard
|
||||||
v-for="task in tasksByStatus(status.id)"
|
v-for="task in tasksByCategory(cat)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
show-project-color
|
show-project-color
|
||||||
|
show-status-badge
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="tasksByStatus(status.id).length === 0"
|
v-if="tasksByCategory(cat).length === 0"
|
||||||
class="py-4 text-center text-xs text-neutral-400"
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
>
|
>
|
||||||
{{ $t('myTasks.noTasks') }}
|
{{ $t('myTasks.noTasks') }}
|
||||||
@@ -452,15 +423,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backlog below kanban -->
|
<!-- Backlog below kanban (no drag/drop — status change goes through TaskModal) -->
|
||||||
<div
|
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
|
||||||
class="mt-8 rounded-lg p-4 transition-colors"
|
|
||||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
|
||||||
@dragover.prevent
|
|
||||||
@dragenter.prevent="onDragEnter(0)"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop.prevent="onDropBacklog($event)"
|
|
||||||
>
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -468,6 +432,7 @@ onMounted(async () => {
|
|||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
show-project-color
|
show-project-color
|
||||||
|
show-status-badge
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,6 +457,8 @@ onMounted(async () => {
|
|||||||
:priorities="priorities"
|
:priorities="priorities"
|
||||||
:efforts="efforts"
|
:efforts="efforts"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
|
:selected-tasks="selectedTasksArray"
|
||||||
|
:projects="projects"
|
||||||
@toggle-all="toggleSelectAll(tasks)"
|
@toggle-all="toggleSelectAll(tasks)"
|
||||||
@bulk-update="onBulkUpdate"
|
@bulk-update="onBulkUpdate"
|
||||||
@bulk-archive="onBulkArchive"
|
@bulk-archive="onBulkArchive"
|
||||||
|
|||||||
@@ -104,21 +104,19 @@
|
|||||||
:placeholder="$t('clientTicket.rejectComment')"
|
:placeholder="$t('clientTicket.rejectComment')"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4 flex justify-end gap-3">
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancelReject"
|
@click="cancelReject"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
:label="$t('clientTicket.status.rejected')"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
|
||||||
:disabled="!rejectComment.trim()"
|
:disabled="!rejectComment.trim()"
|
||||||
@click="confirmReject"
|
@click="confirmReject"
|
||||||
>
|
/>
|
||||||
{{ $t('clientTicket.status.rejected') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,15 +37,10 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:label="$t('clientTicket.description')"
|
:label="$t('clientTicket.description')"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
min-resize-width="100%"
|
|
||||||
max-resize-width="100%"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,13 +69,12 @@
|
|||||||
>
|
>
|
||||||
{{ $t('common.cancel') }}
|
{{ $t('common.cancel') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('portal.submitTicket')"
|
||||||
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"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
{{ $t('portal.submitTicket') }}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,15 +26,64 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<MalioButton
|
||||||
v-if="auth.user?.avatarUrl"
|
v-if="auth.user?.avatarUrl"
|
||||||
type="button"
|
variant="danger"
|
||||||
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="removing"
|
:disabled="removing"
|
||||||
|
:label="$t('profile.removeAvatar')"
|
||||||
@click="onRemove"
|
@click="onRemove"
|
||||||
>
|
/>
|
||||||
{{ $t('profile.removeAvatar') }}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Token MCP (interne uniquement) -->
|
||||||
|
<div
|
||||||
|
v-if="!isClientOnly"
|
||||||
|
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||||
|
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||||
|
|
||||||
|
<div v-if="auth.user?.apiToken">
|
||||||
|
<MalioInputPassword
|
||||||
|
:model-value="auth.user.apiToken"
|
||||||
|
:label="$t('profile.apiToken.label')"
|
||||||
|
readonly
|
||||||
|
@update:model-value="() => {}"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:content-copy"
|
||||||
|
icon-position="left"
|
||||||
|
:label="$t('profile.apiToken.copy')"
|
||||||
|
@click="onCopy"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:refresh"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="showConfirm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:key-plus"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.generate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,14 +94,45 @@
|
|||||||
@crop="onCrop"
|
@crop="onCrop"
|
||||||
@cancel="selectedFile = null"
|
@cancel="selectedFile = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Confirm regenerate modal -->
|
||||||
|
<Teleport v-if="showConfirm" to="body">
|
||||||
|
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ $t('profile.apiToken.confirmMessage') }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
@click="showConfirm = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
import { useApiTokenService } from '~/services/api-token'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isClientOnly = computed(() =>
|
const isClientOnly = computed(() =>
|
||||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
@@ -62,9 +142,12 @@ definePageMeta({
|
|||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
const { upload, remove } = useAvatarService()
|
const { upload, remove } = useAvatarService()
|
||||||
|
const { regenerate } = useApiTokenService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
const removing = ref(false)
|
const removing = ref(false)
|
||||||
|
const regenerating = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
function onFileSelect(event: Event) {
|
function onFileSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
@@ -98,4 +181,28 @@ async function onRemove() {
|
|||||||
removing.value = false
|
removing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
if (!auth.user?.apiToken) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(auth.user.apiToken)
|
||||||
|
toast.success({ message: t('profile.apiToken.copied') })
|
||||||
|
} catch {
|
||||||
|
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRegenerate() {
|
||||||
|
regenerating.value = true
|
||||||
|
try {
|
||||||
|
const newToken = await regenerate()
|
||||||
|
if (auth.user) {
|
||||||
|
auth.user.apiToken = newToken
|
||||||
|
}
|
||||||
|
showConfirm.value = false
|
||||||
|
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||||
|
} finally {
|
||||||
|
regenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
import { useTaskTagService } from '~/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
@@ -96,7 +95,6 @@ useHead({ title: 'Archives' })
|
|||||||
|
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
const priorityService = useTaskPriorityService()
|
const priorityService = useTaskPriorityService()
|
||||||
const tagService = useTaskTagService()
|
const tagService = useTaskTagService()
|
||||||
@@ -105,8 +103,11 @@ const userService = useUserService()
|
|||||||
|
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const archivedTasks = ref<Task[]>([])
|
const archivedTasks = ref<Task[]>([])
|
||||||
const statuses = ref<TaskStatus[]>([])
|
|
||||||
const efforts = ref<TaskEffort[]>([])
|
const efforts = ref<TaskEffort[]>([])
|
||||||
|
|
||||||
|
const statuses = computed<TaskStatus[]>(() =>
|
||||||
|
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||||
|
)
|
||||||
const priorities = ref<TaskPriority[]>([])
|
const priorities = ref<TaskPriority[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
const groups = ref<TaskGroup[]>([])
|
const groups = ref<TaskGroup[]>([])
|
||||||
@@ -126,10 +127,9 @@ const filteredTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
const [p, t, e, pr, ty, g, u] = await Promise.all([
|
||||||
projectService.getById(projectId.value),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value, true),
|
taskService.getByProject(projectId.value, true),
|
||||||
statusService.getAll(),
|
|
||||||
effortService.getAll(),
|
effortService.getAll(),
|
||||||
priorityService.getAll(),
|
priorityService.getAll(),
|
||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
@@ -138,7 +138,6 @@ async function loadData() {
|
|||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
archivedTasks.value = t
|
archivedTasks.value = t
|
||||||
statuses.value = s
|
|
||||||
efforts.value = e
|
efforts.value = e
|
||||||
priorities.value = pr
|
priorities.value = pr
|
||||||
tags.value = ty
|
tags.value = ty
|
||||||
|
|||||||
@@ -60,20 +60,20 @@
|
|||||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
icon="mdi:swap-horizontal"
|
||||||
:title="$t('clientTicket.changeStatus')"
|
:aria-label="$t('clientTicket.changeStatus')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
@click.stop="openStatusChange(ticket)"
|
@click.stop="openStatusChange(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:swap-horizontal" size="18" />
|
<MalioButtonIcon
|
||||||
</button>
|
icon="mdi:delete-outline"
|
||||||
<button
|
aria-label="Supprimer"
|
||||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
variant="ghost"
|
||||||
title="Supprimer"
|
icon-size="18"
|
||||||
@click.stop="onDelete(ticket)"
|
@click.stop="onDelete(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
<Icon
|
<Icon
|
||||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||||
size="20"
|
size="20"
|
||||||
@@ -84,7 +84,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
@@ -143,19 +148,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="statusModalOpen = false"
|
@click="statusModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
label="Confirmer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
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"
|
|
||||||
:disabled="isUpdatingStatus"
|
:disabled="isUpdatingStatus"
|
||||||
@click="confirmStatusChange"
|
@click="confirmStatusChange"
|
||||||
>
|
/>
|
||||||
Confirmer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,17 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 shrink-0"
|
||||||
@click="openTaskCreate"
|
@click="openTaskCreate"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
<span class="hidden sm:inline">Ajouter un ticket</span>
|
||||||
<span class="sm:hidden">+ Ticket</span>
|
<span class="sm:hidden">Ticket</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
<button
|
<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'
|
:class="viewMode === 'list'
|
||||||
? 'border-primary-500 bg-primary-500 text-white'
|
? 'border-primary-500 bg-primary-500 text-white'
|
||||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||||
@@ -21,13 +23,12 @@
|
|||||||
>
|
>
|
||||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
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"
|
icon="heroicons:cog-6-tooth"
|
||||||
title="Paramètres du projet"
|
aria-label="Paramètres du projet"
|
||||||
|
variant="ghost"
|
||||||
@click="projectDrawerOpen = true"
|
@click="projectDrawerOpen = true"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -216,7 +218,6 @@ import type { Client } from '~/services/dto/client'
|
|||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useClientService } from '~/services/clients'
|
import { useClientService } from '~/services/clients'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
import { useTaskTagService } from '~/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
@@ -232,7 +233,6 @@ useHead({ title: 'Projet' })
|
|||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const clientService = useClientService()
|
const clientService = useClientService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
const priorityService = useTaskPriorityService()
|
const priorityService = useTaskPriorityService()
|
||||||
const tagService = useTaskTagService()
|
const tagService = useTaskTagService()
|
||||||
@@ -241,7 +241,6 @@ const userService = useUserService()
|
|||||||
|
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const statuses = ref<TaskStatus[]>([])
|
|
||||||
const efforts = ref<TaskEffort[]>([])
|
const efforts = ref<TaskEffort[]>([])
|
||||||
const priorities = ref<TaskPriority[]>([])
|
const priorities = ref<TaskPriority[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
@@ -250,6 +249,10 @@ const users = ref<UserData[]>([])
|
|||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const statuses = computed<TaskStatus[]>(() =>
|
||||||
|
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||||
|
)
|
||||||
|
|
||||||
const selectedGroupId = ref<number | null>(null)
|
const selectedGroupId = ref<number | null>(null)
|
||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedAssigneeId = ref<number | null>(null)
|
const selectedAssigneeId = ref<number | null>(null)
|
||||||
@@ -257,6 +260,12 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const selectedPriorityId = ref<number | null>(null)
|
const selectedPriorityId = ref<number | null>(null)
|
||||||
const selectedEffortId = ref<number | null>(null)
|
const selectedEffortId = ref<number | null>(null)
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
watch(viewMode, (mode) => {
|
||||||
|
if (mode === 'kanban') {
|
||||||
|
selectedStatusId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
@@ -297,7 +306,10 @@ const filteredTasks = computed(() => {
|
|||||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
if (selectedAssigneeId.value) {
|
||||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedAssigneeId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (selectedStatusId.value) {
|
if (selectedStatusId.value) {
|
||||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||||
@@ -322,10 +334,9 @@ const backlogTasks = computed(() =>
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
|
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
|
||||||
projectService.getById(projectId.value),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value),
|
taskService.getByProject(projectId.value),
|
||||||
statusService.getAll(),
|
|
||||||
effortService.getAll(),
|
effortService.getAll(),
|
||||||
priorityService.getAll(),
|
priorityService.getAll(),
|
||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
@@ -335,7 +346,6 @@ async function loadData() {
|
|||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
tasks.value = t
|
tasks.value = t
|
||||||
statuses.value = s
|
|
||||||
efforts.value = e
|
efforts.value = e
|
||||||
priorities.value = pr
|
priorities.value = pr
|
||||||
tags.value = ty
|
tags.value = ty
|
||||||
|
|||||||
@@ -4,23 +4,24 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
variant="tertiary"
|
||||||
:class="showArchived
|
:icon-name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'"
|
||||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
icon-position="left"
|
||||||
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'"
|
button-class="w-auto px-3"
|
||||||
@click="toggleArchived"
|
@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>
|
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 shrink-0"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
|
<span class="hidden sm:inline">{{ $t('projects.addProject') }}</span>
|
||||||
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
|
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,12 +45,13 @@
|
|||||||
{{ $t('common.archived') }}
|
{{ $t('common.archived') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="p-1 text-neutral-400 hover:text-primary-500"
|
icon="mdi:pencil-outline"
|
||||||
|
aria-label="Modifier le projet"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="16"
|
||||||
@click.stop="openEdit(project)"
|
@click.stop="openEdit(project)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:pencil-outline" size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
||||||
{{ project.description ?? '' }}
|
{{ project.description ?? '' }}
|
||||||
|
|||||||
@@ -3,27 +3,35 @@
|
|||||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
<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">
|
<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>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="shrink-0"
|
||||||
@click="openCreateDrawer()"
|
@click="openCreateDrawer()"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">+ Ajouter une Activité</span>
|
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||||
<span class="sm:hidden">+ Activité</span>
|
<span class="sm:hidden">Activité</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
<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">
|
<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">
|
<MalioButtonIcon
|
||||||
<Icon name="mdi:chevron-left" size="20" />
|
icon="mdi:chevron-left"
|
||||||
</button>
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigatePrev"
|
||||||
|
/>
|
||||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
<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">
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||||
{{ currentMonthLabel }}
|
{{ currentMonthLabel }}
|
||||||
</h2>
|
</h2>
|
||||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
<MalioButtonIcon
|
||||||
<Icon name="mdi:chevron-right" size="20" />
|
icon="mdi:chevron-right"
|
||||||
</button>
|
aria-label="Suivant"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigateNext"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||||
@@ -48,7 +56,7 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="User"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,13 +84,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<MalioButton
|
||||||
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"
|
:label="$t('timeEntries.export')"
|
||||||
@click="exportTimeEntries"
|
variant="secondary"
|
||||||
>
|
icon-name="mdi:download"
|
||||||
<Icon name="mdi:download" size="18" />
|
icon-position="left"
|
||||||
{{ $t('timeEntries.export') }}
|
button-class="w-auto px-4"
|
||||||
</button>
|
@click="exportDrawerOpen = true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,6 +137,15 @@
|
|||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TimeTrackingExportDrawer
|
||||||
|
v-model="exportDrawerOpen"
|
||||||
|
:users="users"
|
||||||
|
:projects="projects"
|
||||||
|
:tags="tags"
|
||||||
|
:clients="clients"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,6 +154,7 @@ import type { TimeEntry } from '~/services/dto/time-entry'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useTimeEntryService } from '~/services/time-entries'
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
import type { HydraCollection } from '~/utils/api'
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
@@ -156,6 +175,8 @@ const entries = ref<TimeEntry[]>([])
|
|||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
const exportDrawerOpen = ref(false)
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const editingEntry = ref<TimeEntry | null>(null)
|
const editingEntry = ref<TimeEntry | null>(null)
|
||||||
@@ -196,16 +217,7 @@ function updatePageHeaderHeight() {
|
|||||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => entries.value)
|
||||||
let result = entries.value
|
|
||||||
if (selectedProjectId.value) {
|
|
||||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
|
||||||
}
|
|
||||||
if (selectedTagId.value) {
|
|
||||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
const date = new Date(d)
|
const date = new Date(d)
|
||||||
@@ -218,15 +230,35 @@ function getMonday(d: Date): Date {
|
|||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() - 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() - 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() - 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() + 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() + 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() + 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,63 +337,70 @@ async function onDelete(entry: TimeEntry) {
|
|||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExportDateRange(): { after: string, before: string } {
|
async function onExport(params: {
|
||||||
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
|
after: string
|
||||||
return {
|
before: string
|
||||||
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
|
users?: number[]
|
||||||
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
|
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() {
|
async function loadEntries() {
|
||||||
const end = new Date(startDate.value)
|
const end = new Date(startDate.value)
|
||||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
end.setMonth(end.getMonth() + 1)
|
||||||
|
} else {
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
}
|
||||||
|
|
||||||
entries.value = await timeEntryService.getByDateRange({
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
after: startDate.value.toISOString(),
|
after: startDate.value.toISOString(),
|
||||||
before: end.toISOString(),
|
before: end.toISOString(),
|
||||||
user: selectedUserId.value ?? undefined,
|
user: selectedUserId.value ?? undefined,
|
||||||
|
project: selectedProjectId.value ?? undefined,
|
||||||
|
tag: selectedTagId.value ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReferenceData() {
|
async function loadReferenceData() {
|
||||||
const api = useApi()
|
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<UserData>>('/users'),
|
||||||
api.get<HydraCollection<Project>>('/projects'),
|
api.get<HydraCollection<Project>>('/projects'),
|
||||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||||
|
api.get<HydraCollection<Client>>('/clients'),
|
||||||
])
|
])
|
||||||
|
|
||||||
users.value = extractHydraMembers(usersData)
|
users.value = extractHydraMembers(usersData)
|
||||||
projects.value = extractHydraMembers(projectsData)
|
projects.value = extractHydraMembers(projectsData)
|
||||||
tags.value = extractHydraMembers(typesData)
|
tags.value = extractHydraMembers(typesData)
|
||||||
|
clients.value = extractHydraMembers(clientsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -380,11 +419,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(viewMode, () => {
|
watch(viewMode, () => {
|
||||||
selectedDateFilter.value = null
|
selectedDateFilter.value = null
|
||||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
if (viewMode.value === 'day') {
|
||||||
|
// keep current date
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
const d = new Date(startDate.value)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
startDate.value = getMonday(startDate.value)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedUserId, () => {
|
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
12
frontend/services/api-token.ts
Normal file
12
frontend/services/api-token.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function useApiTokenService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function regenerate(): Promise<string> {
|
||||||
|
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
return data.apiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regenerate }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Client } from './client'
|
import type { Client } from './client'
|
||||||
|
import type { Workflow } from './workflow'
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: number
|
id: number
|
||||||
@@ -8,6 +9,7 @@ export type Project = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
color: string
|
color: string
|
||||||
client: Client | null
|
client: Client | null
|
||||||
|
workflow: Workflow
|
||||||
giteaOwner: string | null
|
giteaOwner: string | null
|
||||||
giteaRepo: string | null
|
giteaRepo: string | null
|
||||||
bookstackShelfId: number | null
|
bookstackShelfId: number | null
|
||||||
@@ -22,6 +24,7 @@ export type ProjectWrite = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
color: string
|
color: string
|
||||||
client: string | null // IRI : "/api/clients/1" ou null
|
client: string | null // IRI : "/api/clients/1" ou null
|
||||||
|
workflow?: string // IRI : "/api/workflows/1"
|
||||||
giteaOwner?: string | null
|
giteaOwner?: string | null
|
||||||
giteaRepo?: string | null
|
giteaRepo?: string | null
|
||||||
bookstackShelfId?: number | null
|
bookstackShelfId?: number | null
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { StatusCategory } from './workflow'
|
||||||
|
|
||||||
export type TaskStatus = {
|
export type TaskStatus = {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
@@ -5,6 +7,8 @@ export type TaskStatus = {
|
|||||||
color: string
|
color: string
|
||||||
position: number
|
position: number
|
||||||
isFinal: boolean
|
isFinal: boolean
|
||||||
|
category: StatusCategory
|
||||||
|
workflow?: { '@id': string, id: number } | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskStatusWrite = {
|
export type TaskStatusWrite = {
|
||||||
@@ -12,4 +16,6 @@ export type TaskStatusWrite = {
|
|||||||
color: string
|
color: string
|
||||||
position: number
|
position: number
|
||||||
isFinal: boolean
|
isFinal: boolean
|
||||||
|
category: StatusCategory
|
||||||
|
workflow?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type Task = {
|
|||||||
effort: TaskEffort | null
|
effort: TaskEffort | null
|
||||||
priority: TaskPriority | null
|
priority: TaskPriority | null
|
||||||
assignee: UserData | null
|
assignee: UserData | null
|
||||||
|
collaborators: UserData[]
|
||||||
group: TaskGroup | null
|
group: TaskGroup | null
|
||||||
project: Project | null
|
project: Project | null
|
||||||
tags: TaskTag[]
|
tags: TaskTag[]
|
||||||
@@ -55,6 +56,7 @@ export type TaskWrite = {
|
|||||||
effort: string | null
|
effort: string | null
|
||||||
priority: string | null
|
priority: string | null
|
||||||
assignee: string | null
|
assignee: string | null
|
||||||
|
collaborators?: string[]
|
||||||
group: string | null
|
group: string | null
|
||||||
project: string
|
project: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type UserData = {
|
|||||||
client?: { id: number; name: string } | null
|
client?: { id: number; name: string } | null
|
||||||
allowedProjects?: Project[]
|
allowedProjects?: Project[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
|
apiToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWrite = {
|
export type UserWrite = {
|
||||||
|
|||||||
27
frontend/services/dto/workflow.ts
Normal file
27
frontend/services/dto/workflow.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { TaskStatus, TaskStatusWrite } from './task-status'
|
||||||
|
|
||||||
|
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
|
||||||
|
|
||||||
|
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||||
|
todo: 'À faire',
|
||||||
|
in_progress: 'En cours',
|
||||||
|
blocked: 'Bloqué',
|
||||||
|
review: 'En validation',
|
||||||
|
done: 'Terminé',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Workflow = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
name: string
|
||||||
|
isDefault: boolean
|
||||||
|
position: number
|
||||||
|
statuses: TaskStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowWrite = {
|
||||||
|
name: string
|
||||||
|
isDefault: boolean
|
||||||
|
position: number
|
||||||
|
statuses?: TaskStatusWrite[]
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
|||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
user?: number
|
||||||
types?: number[]
|
project?: number
|
||||||
|
tag?: number
|
||||||
}): Promise<TimeEntry[]> {
|
}): Promise<TimeEntry[]> {
|
||||||
const query: Record<string, unknown> = {
|
const query: Record<string, unknown> = {
|
||||||
'startedAt[after]': params.after,
|
'startedAt[after]': params.after,
|
||||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
|||||||
if (params.user) {
|
if (params.user) {
|
||||||
query.user = `/api/users/${params.user}`
|
query.user = `/api/users/${params.user}`
|
||||||
}
|
}
|
||||||
|
if (params.project) {
|
||||||
|
query.project = `/api/projects/${params.project}`
|
||||||
|
}
|
||||||
|
if (params.tag) {
|
||||||
|
query['tags[]'] = `/api/task_tags/${params.tag}`
|
||||||
|
}
|
||||||
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
@@ -53,20 +60,42 @@ export function useTimeEntryService() {
|
|||||||
function getExportUrl(params: {
|
function getExportUrl(params: {
|
||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
users?: number[]
|
||||||
project?: number
|
projects?: number[]
|
||||||
|
client?: number
|
||||||
tags?: number[]
|
tags?: number[]
|
||||||
}): string {
|
}): string {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
query.set('after', params.after)
|
query.set('after', params.after)
|
||||||
query.set('before', params.before)
|
query.set('before', params.before)
|
||||||
if (params.user) query.set('user', String(params.user))
|
if (params.users?.length) {
|
||||||
if (params.project) query.set('project', String(params.project))
|
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) {
|
if (params.tags?.length) {
|
||||||
params.tags.forEach(id => query.append('tags[]', String(id)))
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/services/workflows.ts
Normal file
55
frontend/services/workflows.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Workflow, WorkflowWrite } from './dto/workflow'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
type SwitchPayload = {
|
||||||
|
workflowId: number
|
||||||
|
mapping: Record<string, number | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchResult = {
|
||||||
|
projectId: number
|
||||||
|
workflowId: number
|
||||||
|
migratedTaskCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<Workflow[]> {
|
||||||
|
const data = await api.get<HydraCollection<Workflow>>('/workflows')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOne(id: number): Promise<Workflow> {
|
||||||
|
return api.get<Workflow>(`/workflows/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: WorkflowWrite): Promise<Workflow> {
|
||||||
|
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'workflows.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
|
||||||
|
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'workflows.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/workflows/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'workflows.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
|
||||||
|
return api.post<SwitchResult>(
|
||||||
|
`/projects/${projectId}/switch-workflow`,
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
{ toastSuccessKey: 'workflows.switched' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getOne, create, update, remove, switchOnProject }
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {Config} from 'tailwindcss'
|
import type {Config} from 'tailwindcss'
|
||||||
|
import typography from '@tailwindcss/typography'
|
||||||
|
|
||||||
export default <Partial<Config>>{
|
export default <Partial<Config>>{
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
|
plugins: [typography],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
@@ -19,6 +21,28 @@ export default <Partial<Config>>{
|
|||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
500: '#056CF2'
|
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>)',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user