Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66caf6824 | ||
|
|
96cbb45e61 | ||
|
|
a8b899f7c4 | ||
|
|
766fddd417 | ||
|
|
1219f3e73e | ||
|
|
ec35a1b2aa | ||
|
|
0113c08a60 | ||
|
|
c176511d97 | ||
|
|
64de971872 | ||
|
|
3dcc5c21a2 | ||
|
|
47768c0f02 | ||
|
|
b278b8a23a | ||
|
|
4074457499 | ||
|
|
b29b4d304d | ||
|
|
dd9db93751 | ||
|
|
3e2f3b3cf8 | ||
|
|
5bf768bc02 | ||
|
|
77c7ceb064 | ||
|
|
ac36eeba36 | ||
|
|
005b731a97 | ||
|
|
3df0b15fe7 | ||
|
|
8040245e45 | ||
|
|
5d378c1f75 | ||
|
|
8544babf8c | ||
|
|
455121132d | ||
|
|
fd3097cc26 | ||
|
|
ff7cff1d39 | ||
|
|
ed58a402b0 | ||
|
|
2ac815d074 | ||
|
|
e0dfcbdbf8 | ||
|
|
5db6b1e2b0 | ||
|
|
6e29aeb30f | ||
|
|
cca548dfbc | ||
|
|
3d4b7fad12 | ||
|
|
5ffb4bbedc | ||
|
|
d2e9f9ed65 | ||
|
|
c5898fbf74 | ||
|
|
0180dd3715 |
6
.env
6
.env
@@ -1,5 +1,5 @@
|
||||
APP_ENV=dev
|
||||
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
APP_DEBUG=1
|
||||
|
||||
DEFAULT_URI=http://localhost/
|
||||
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
ENCRYPTION_KEY=aaaaaaaaa
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
99
.env.example
Normal file
99
.env.example
Normal file
@@ -0,0 +1,99 @@
|
||||
###############################################################################
|
||||
# Lesstime — Fichier d'environnement de reference
|
||||
#
|
||||
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
|
||||
# Les valeurs par defaut dans .env suffisent pour le developpement ;
|
||||
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
|
||||
# etre definis dans .env.local.
|
||||
#
|
||||
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
|
||||
###############################################################################
|
||||
|
||||
# ===========================================================================
|
||||
# App
|
||||
# ===========================================================================
|
||||
|
||||
# Environnement Symfony : dev, test, prod
|
||||
APP_ENV=dev
|
||||
|
||||
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
|
||||
# Active/desactive le mode debug (1 = oui, 0 = non)
|
||||
APP_DEBUG=1
|
||||
|
||||
# URI par defaut de l'application (utilise pour les liens absolus)
|
||||
DEFAULT_URI=http://localhost/
|
||||
|
||||
# ===========================================================================
|
||||
# CORS (nelmio/cors-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Origines autorisees pour les requetes cross-origin (regex)
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
# ===========================================================================
|
||||
# JWT (lexik/jwt-authentication-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Chemin vers la cle privee RSA pour signer les tokens JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
|
||||
# Chemin vers la cle publique RSA pour verifier les tokens JWT
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
|
||||
# Passphrase de la cle privee JWT — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
|
||||
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
|
||||
JWT_COOKIE_SECURE=0
|
||||
|
||||
# Duree de vie du token JWT en secondes (86400 = 24h)
|
||||
JWT_TOKEN_TTL=86400
|
||||
|
||||
# Duree de vie du cookie JWT en secondes (86400 = 24h)
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# ===========================================================================
|
||||
# Base de donnees (Doctrine / PostgreSQL)
|
||||
# ===========================================================================
|
||||
|
||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||
# et injectees automatiquement par Docker Compose.
|
||||
# DATABASE_URL est construite a partir de ces variables.
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
# ===========================================================================
|
||||
# Chiffrement
|
||||
# ===========================================================================
|
||||
|
||||
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
|
||||
# ===========================================================================
|
||||
# Docker (docker/.env.docker)
|
||||
#
|
||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# surcharger localement.
|
||||
# ===========================================================================
|
||||
|
||||
# DOCKER_APP_NAME=lesstime
|
||||
# DOCKER_PHP_VERSION=8.4.6
|
||||
# DOCKER_NODE_VERSION=24.12.0
|
||||
# APP_USER=www-data
|
||||
# POSTGRES_DB=lesstime
|
||||
# POSTGRES_USER=root
|
||||
# POSTGRES_PASSWORD=root
|
||||
# POSTGRES_PORT=5435
|
||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
|
||||
# ===========================================================================
|
||||
# Frontend (frontend/.env)
|
||||
# ===========================================================================
|
||||
|
||||
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
|
||||
# NUXT_PUBLIC_API_BASE=/api
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
/config/jwt/*.pem
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
###> ide ###
|
||||
.idea/
|
||||
###< ide ###
|
||||
|
||||
###> docker local ###
|
||||
docker/.env.docker.local
|
||||
###< docker local ###
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
8
.idea/Lesstime.iml
generated
8
.idea/Lesstime.iml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/db-forest-config.xml
generated
6
.idea/db-forest-config.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/material_theme_project_new.xml
generated
10
.idea/material_theme_project_new.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="386cba74:19cc24e9181:-799b" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/php.xml
generated
20
.idea/php.xml
generated
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -12,7 +12,7 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
|
||||
src/Service/ # Services métier (NotificationService)
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*"
|
||||
},
|
||||
@@ -90,8 +91,6 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/css-selector": "8.0.*"
|
||||
"phpunit/phpunit": "^13.0"
|
||||
}
|
||||
}
|
||||
|
||||
958
composer.lock
generated
958
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,11 @@ use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
|
||||
return [
|
||||
FrameworkBundle::class => ['all' => true],
|
||||
TwigBundle::class => ['all' => true],
|
||||
SecurityBundle::class => ['all' => true],
|
||||
DoctrineBundle::class => ['all' => true],
|
||||
DoctrineMigrationsBundle::class => ['all' => true],
|
||||
@@ -24,4 +23,5 @@ return [
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
api_platform:
|
||||
title: Hello API Platform
|
||||
title: Lesstime API
|
||||
version: 1.0.0
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
|
||||
@@ -19,5 +19,5 @@ mcp:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||
ttl: 3600
|
||||
|
||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
monolog:
|
||||
channels:
|
||||
- deprecation
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
max_files: 7
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!event"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
max_files: 30
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: rotating_file
|
||||
channels: [deprecation]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
max_files: 7
|
||||
@@ -22,6 +22,9 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
interval: '1 minute'
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -59,6 +62,7 @@ security:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.2.0'
|
||||
app.version: '0.3.5'
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
DOCKER_APP_NAME=lesstime
|
||||
DOCKER_PHP_VERSION=8.4.6
|
||||
DOCKER_NODE_VERSION=24.12.0
|
||||
APP_USER=www-data
|
||||
POSTGRES_DB=lesstime
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5435
|
||||
XDEBUG_CLIENT_HOST=192.168.0.124
|
||||
@@ -74,7 +74,7 @@ sudo chmod 600 /etc/lesstime-release-token
|
||||
## 5. Deployer une release
|
||||
|
||||
```bash
|
||||
sudo /usr/local/bin/deploy-lesstime v0.2.0
|
||||
sudo /usr/local/bin/deploy-lesstime v0.2.1
|
||||
```
|
||||
|
||||
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
|
||||
|
||||
@@ -274,25 +274,22 @@ const availableStatusTransitions = computed(() => {
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const match = iri.match(/\/api\/projects\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterName(iri: string | null): string {
|
||||
if (!iri) return '-'
|
||||
const match = iri.match(/\/api\/users\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return users.value.find(u => u.id === id)?.username ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||
if (!iri) return undefined
|
||||
const match = iri.match(/\/api\/users\/(\d+)/)
|
||||
if (!match) return undefined
|
||||
const id = Number(match[1])
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return undefined
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
v-model="editForm.description"
|
||||
rows="5"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +192,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
@@ -243,7 +244,7 @@ const canEdit = computed(() => {
|
||||
if (!sub) return false
|
||||
// submittedBy can be an IRI string or an embedded object
|
||||
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
|
||||
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -270,7 +271,7 @@ async function saveEdit() {
|
||||
if (props.ticket.type === 'bug') {
|
||||
data.url = editForm.url || null
|
||||
}
|
||||
await clientTicketService.update(props.ticket.id, data as any)
|
||||
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
|
||||
isEditing.value = false
|
||||
emit('refresh')
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
:disabled="isSubmitting"
|
||||
@@ -73,7 +73,21 @@
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="project.taskCount === 0"
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +118,7 @@ const isOpen = computed({
|
||||
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useProjectService()
|
||||
const { create, update, remove } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
@@ -213,6 +228,19 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.project.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
confirmDeleteOpen.value = false
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
|
||||
131
frontend/components/task/TaskBulkActions.vue
Normal file
131
frontend/components/task/TaskBulkActions.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click="emit('bulk-delete')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
</script>
|
||||
@@ -9,7 +9,17 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
@@ -63,9 +73,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
showProjectColor?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -267,7 +267,7 @@ async function handleArchive() {
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
|
||||
108
frontend/components/task/TaskListItem.vue
Normal file
108
frontend/components/task/TaskListItem.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@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
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
@@ -24,7 +24,7 @@
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
|
||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -65,6 +65,20 @@
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
|
||||
<!-- Project select (create mode with project list) -->
|
||||
<div v-if="showProjectSelect" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.projectId"
|
||||
:options="projectOptions"
|
||||
label="Projet *"
|
||||
empty-option-label="Sélectionner un projet"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||
Le projet est requis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-column selects -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<MalioSelect
|
||||
@@ -142,7 +156,12 @@
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -266,6 +285,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
task: Task | null
|
||||
@@ -276,6 +297,7 @@ const props = defineProps<{
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
projects?: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -318,10 +340,12 @@ const form = reactive({
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
project: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
@@ -340,8 +364,22 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.map(g => ({ label: g.title, value: g.id }))
|
||||
const groupOptions = computed(() => {
|
||||
let filtered = props.groups
|
||||
if (showProjectSelect.value && form.projectId) {
|
||||
filtered = filtered.filter(g => g.project?.id === form.projectId)
|
||||
}
|
||||
return filtered.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const resolvedProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
@@ -385,8 +423,10 @@ function populateForm(task: Task | null) {
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
}
|
||||
touched.title = false
|
||||
touched.project = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
@@ -394,9 +434,14 @@ watch(() => props.modelValue, async (open) => {
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
populateForm(props.task)
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
} catch {
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
@@ -426,6 +471,22 @@ const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// Reset group and reload client tickets when project changes in create mode
|
||||
watch(() => form.projectId, async (pid) => {
|
||||
if (!showProjectSelect.value) return
|
||||
form.groupId = null
|
||||
form.clientTicketId = null
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
@@ -512,7 +573,7 @@ async function handleArchive() {
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
@@ -541,7 +602,9 @@ async function handleUnarchive() {
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
touched.project = true
|
||||
if (!form.title.trim()) return
|
||||
if (showProjectSelect.value && !form.projectId) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -553,7 +616,7 @@ async function handleSubmit() {
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="blockEl"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||
:style="blockStyle"
|
||||
:class="{ 'opacity-40': isDragSource }"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@@ -17,38 +17,39 @@
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
|
||||
<div class="px-1.5 py-0.5 h-full overflow-hidden">
|
||||
<!-- Full display: title + project + type dot + duration -->
|
||||
<template v-if="sizeLevel >= 3">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
||||
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
|
||||
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
||||
<!-- Top: title + project -->
|
||||
<div class="min-w-0">
|
||||
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom: tags left, duration right -->
|
||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Medium: title + duration -->
|
||||
<template v-else-if="sizeLevel === 2">
|
||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Small: title only -->
|
||||
<template v-else-if="sizeLevel === 1">
|
||||
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Tiny: just a colored bar, no text -->
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle bottom (outside block) -->
|
||||
@@ -116,10 +117,22 @@ const sizeLevel = computed(() => {
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||
const bgColor = props.entry.project?.color ?? '#94a3b8'
|
||||
|
||||
const col = props.columnIndex ?? 0
|
||||
const total = props.totalColumns ?? 1
|
||||
@@ -127,13 +140,28 @@ const blockStyle = computed(() => {
|
||||
const leftPercent = (col / total) * 100
|
||||
const widthPercent = (1 / total) * 100
|
||||
|
||||
return {
|
||||
const base: Record<string, string> = {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx.value}px`,
|
||||
backgroundColor: bgColor,
|
||||
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||
}
|
||||
|
||||
if (hasProject.value) {
|
||||
const hex = props.entry.project!.color.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
||||
base.color = `rgb(${r}, ${g}, ${b})`
|
||||
} else {
|
||||
base.backgroundColor = '#e5e7eb'
|
||||
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
||||
base.backgroundSize = '12px 12px'
|
||||
base.color = '#6b7280'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Click / Drag detection ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -105,19 +105,29 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
@@ -231,6 +241,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
@@ -257,7 +287,7 @@ async function onSubmit() {
|
||||
if (isEditing.value && props.entry) {
|
||||
await update(props.entry.id, payload)
|
||||
} else {
|
||||
await create(payload as any)
|
||||
await create(payload as TimeEntryWrite)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
||||
Aucune activité pour cette période
|
||||
{{ $t('timeEntries.noEntries') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
@click="emit('editEntry', entry)"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
@@ -18,14 +18,14 @@
|
||||
|
||||
<!-- Main info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || 'Sans titre' }}
|
||||
</span>
|
||||
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- Delete action -->
|
||||
<button
|
||||
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
title="Supprimer"
|
||||
:title="$t('common.delete')"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg"
|
||||
>
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
<!-- Grid body with sticky header -->
|
||||
<div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Day headers (sticky inside scroll container) -->
|
||||
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
</div>
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid body -->
|
||||
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Columns -->
|
||||
<div class="relative flex">
|
||||
<!-- Hour labels -->
|
||||
<div class="w-16 shrink-0">
|
||||
<div
|
||||
@@ -99,7 +99,7 @@
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div class="text-[10px] text-neutral-500">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
@@ -134,13 +134,16 @@
|
||||
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end columns flex -->
|
||||
</div><!-- end gridBodyEl -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
startDate: Date
|
||||
@@ -198,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
|
||||
// Scroll to current hour on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!calendarEl.value) return
|
||||
const scrollParent = getScrollParent()
|
||||
if (!scrollParent) return
|
||||
if (!gridBodyEl.value) return
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const calendarTop = calendarEl.value.offsetTop
|
||||
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
||||
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
||||
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -459,7 +459,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
|
||||
dragState.value = {
|
||||
entryId: entry.id,
|
||||
entry,
|
||||
title: entry.title || 'Sans titre',
|
||||
title: entry.title || t('common.untitled'),
|
||||
color: entry.project?.color ?? '#94a3b8',
|
||||
durationMinutes,
|
||||
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
|
||||
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<button
|
||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
||||
@@ -7,6 +7,17 @@
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
|
||||
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
@click="toggleTitle"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<NotificationBell />
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
@@ -45,6 +56,13 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
||||
|
||||
function toggleTitle() {
|
||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
||||
localStorage.setItem('appTitle', appTitle.value)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('projects.deleteConfirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('projects.deleteConfirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,19 +4,18 @@
|
||||
<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">Supprimer le statut « {{ statusLabel }} »</h3>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
||||
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
|
||||
Choisissez où les déplacer :
|
||||
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="targetStatusId"
|
||||
:options="targetOptions"
|
||||
label="Déplacer vers"
|
||||
empty-option-label="Backlog (sans statut)"
|
||||
:label="$t('taskStatuses.moveTo')"
|
||||
:empty-option-label="$t('taskStatuses.backlog')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -27,7 +26,7 @@
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
@click="cancel"
|
||||
>
|
||||
Annuler
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -35,7 +34,7 @@
|
||||
:disabled="isProcessing"
|
||||
@click="confirm"
|
||||
>
|
||||
Supprimer
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="date-filter">
|
||||
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
|
||||
<VueDatePicker
|
||||
ref="datepicker"
|
||||
v-model="internalValue"
|
||||
@@ -14,45 +14,11 @@
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
|
||||
<button
|
||||
class="px-2 py-[7px] text-xs font-medium transition"
|
||||
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||
@click.stop="switchMode('day')"
|
||||
>
|
||||
{{ t('common.day') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-[7px] text-xs font-medium transition"
|
||||
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||
@click.stop="switchMode('week')"
|
||||
>
|
||||
{{ t('common.weekShort') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<input
|
||||
:value="displayValue"
|
||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||
:placeholder="t('common.dateFilter')"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
v-if="internalValue"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="mdi:calendar"
|
||||
size="16"
|
||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
|
||||
>
|
||||
<Icon name="mdi:calendar-blank" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #action-buttons>
|
||||
@@ -85,6 +51,7 @@ const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
modelValue?: Date | [Date, Date] | null
|
||||
placeholder?: string
|
||||
pickerMode?: 'day' | 'week'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -92,7 +59,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
|
||||
const mode = ref<'day' | 'week'>('week')
|
||||
const mode = computed(() => props.pickerMode ?? 'week')
|
||||
const internalValue = ref<Date | Date[] | null>(null)
|
||||
|
||||
const displayValue = computed(() => {
|
||||
@@ -133,13 +100,6 @@ function formatShortDate(d: Date): string {
|
||||
return `${day}/${month}`
|
||||
}
|
||||
|
||||
function switchMode(newMode: 'day' | 'week') {
|
||||
if (mode.value === newMode) return
|
||||
mode.value = newMode
|
||||
internalValue.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function onUpdate(value: Date | Date[] | null) {
|
||||
if (!value) {
|
||||
emit('update:modelValue', null)
|
||||
@@ -163,7 +123,6 @@ function onClear() {
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
mode.value = 'day'
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
internalValue.value = today
|
||||
@@ -171,7 +130,6 @@ function selectToday() {
|
||||
}
|
||||
|
||||
function selectThisWeek() {
|
||||
mode.value = 'week'
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
|
||||
class="flex items-center justify-center gap-2 text-sm font-semibold text-white transition"
|
||||
:class="[
|
||||
timerStore.isRunning
|
||||
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||
: 'bg-primary-500 hover:bg-primary-600',
|
||||
collapsed ? 'px-2' : 'px-4'
|
||||
collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
|
||||
]"
|
||||
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -90,6 +90,8 @@ import { useProjectService } from '~/services/projects'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: UserData | null
|
||||
@@ -114,7 +116,7 @@ const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: 'Aucun client', value: null as number | null },
|
||||
{ label: t('common.noClient'), value: null as number | null },
|
||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
||||
])
|
||||
|
||||
@@ -146,6 +148,13 @@ function onClientChange(value: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.roles, (roles) => {
|
||||
if (!roles.includes('ROLE_CLIENT')) {
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
@@ -187,10 +196,12 @@ async function handleSubmit() {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
|
||||
allowedProjects: form.clientId !== null
|
||||
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
|
||||
: [],
|
||||
}
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
payload.plainPassword = form.password
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
|
||||
@@ -177,13 +177,16 @@ export function useApi(): ApiClient {
|
||||
) {
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
if (!isFormData) {
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
|
||||
@@ -5,11 +5,13 @@ export function useAvatarService() {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, 'avatar.png')
|
||||
|
||||
return $fetch(`/api/users/${userId}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return api.post<{ avatarUrl: string }>(
|
||||
`/users/${userId}/avatar`,
|
||||
formData as unknown as Record<string, unknown>,
|
||||
{
|
||||
toastSuccessKey: 'profile.avatarUpdated',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function remove(userId: number): Promise<void> {
|
||||
|
||||
@@ -22,43 +22,69 @@
|
||||
"clients": {
|
||||
"created": "Client créé avec succès.",
|
||||
"updated": "Client mis à jour avec succès.",
|
||||
"deleted": "Client supprimé avec succès."
|
||||
"deleted": "Client supprimé avec succès.",
|
||||
"addClient": "Ajouter un client",
|
||||
"editClient": "Modifier un client"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projets",
|
||||
"created": "Projet créé avec succès.",
|
||||
"updated": "Projet mis à jour avec succès.",
|
||||
"deleted": "Projet supprimé avec succès.",
|
||||
"archived": "Projet archivé avec succès.",
|
||||
"unarchived": "Projet désarchivé avec succès.",
|
||||
"showArchived": "Voir les projets archivés",
|
||||
"hideArchived": "Masquer les projets archivés"
|
||||
"hideArchived": "Masquer les projets archivés",
|
||||
"noProjects": "Aucun projet trouvé.",
|
||||
"noArchivedProjects": "Aucun projet archivé.",
|
||||
"addProject": "Ajouter un projet",
|
||||
"addProjectShort": "Projet",
|
||||
"editProject": "Modifier un projet",
|
||||
"deleteConfirmTitle": "Supprimer le projet",
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
|
||||
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
|
||||
},
|
||||
"taskStatuses": {
|
||||
"created": "Statut créé avec succès.",
|
||||
"updated": "Statut mis à jour avec succès.",
|
||||
"deleted": "Statut supprimé avec succès."
|
||||
"deleted": "Statut supprimé avec succès.",
|
||||
"addStatus": "Ajouter un statut",
|
||||
"editStatus": "Modifier un statut",
|
||||
"deleteStatus": "Supprimer le statut « {label} »",
|
||||
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
|
||||
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
|
||||
"moveTo": "Déplacer vers",
|
||||
"backlog": "Backlog (sans statut)"
|
||||
},
|
||||
"taskEfforts": {
|
||||
"created": "Effort créé avec succès.",
|
||||
"updated": "Effort mis à jour avec succès.",
|
||||
"deleted": "Effort supprimé avec succès."
|
||||
"deleted": "Effort supprimé avec succès.",
|
||||
"addEffort": "Ajouter un effort",
|
||||
"editEffort": "Modifier un effort"
|
||||
},
|
||||
"taskPriorities": {
|
||||
"created": "Priorité créée avec succès.",
|
||||
"updated": "Priorité mise à jour avec succès.",
|
||||
"deleted": "Priorité supprimée avec succès."
|
||||
"deleted": "Priorité supprimée avec succès.",
|
||||
"addPriority": "Ajouter une priorité",
|
||||
"editPriority": "Modifier une priorité"
|
||||
},
|
||||
"taskTags": {
|
||||
"created": "Tag créé avec succès.",
|
||||
"updated": "Tag mis à jour avec succès.",
|
||||
"deleted": "Tag supprimé avec succès."
|
||||
"deleted": "Tag supprimé avec succès.",
|
||||
"addTag": "Ajouter un tag",
|
||||
"editTag": "Modifier un tag"
|
||||
},
|
||||
"taskGroups": {
|
||||
"created": "Groupe créé avec succès.",
|
||||
"updated": "Groupe mis à jour avec succès.",
|
||||
"deleted": "Groupe supprimé avec succès.",
|
||||
"archived": "Groupe archivé avec succès.",
|
||||
"unarchived": "Groupe désarchivé avec succès."
|
||||
"unarchived": "Groupe désarchivé avec succès.",
|
||||
"addGroup": "Ajouter un groupe",
|
||||
"editGroup": "Modifier un groupe"
|
||||
},
|
||||
"taskDocuments": {
|
||||
"title": "Documents",
|
||||
@@ -78,17 +104,24 @@
|
||||
"archived": "Ticket archivé avec succès.",
|
||||
"unarchived": "Ticket désarchivé avec succès.",
|
||||
"deleteConfirmTitle": "Supprimer le ticket",
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"addTask": "Ajouter un ticket",
|
||||
"editTask": "Modifier un ticket"
|
||||
},
|
||||
"users": {
|
||||
"created": "Utilisateur créé avec succès.",
|
||||
"updated": "Utilisateur mis à jour avec succès.",
|
||||
"deleted": "Utilisateur supprimé avec succès."
|
||||
"deleted": "Utilisateur supprimé avec succès.",
|
||||
"addUser": "Ajouter un utilisateur",
|
||||
"editUser": "Modifier un utilisateur"
|
||||
},
|
||||
"timeEntries": {
|
||||
"created": "Temps enregistré",
|
||||
"updated": "Temps modifié",
|
||||
"deleted": "Temps supprimé"
|
||||
"deleted": "Temps supprimé",
|
||||
"noEntries": "Aucune activité pour cette période",
|
||||
"addEntry": "Ajouter une Activité",
|
||||
"editEntry": "Modifier un temps"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
@@ -112,7 +145,8 @@
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
"backlog": "Backlog",
|
||||
"createTask": "Créer une tâche"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -168,7 +202,12 @@
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"add": "Ajouter",
|
||||
"loading": "Chargement...",
|
||||
"archived": "Archivé",
|
||||
"noClient": "Aucun client",
|
||||
"untitled": "Sans titre",
|
||||
"dateFilter": "Date",
|
||||
"today": "Aujourd'hui",
|
||||
"thisWeek": "Cette semaine",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
|
||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||
<img
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
@@ -26,9 +26,9 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/malio.png"
|
||||
src="/LOGO_CARRE.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-cover object-left"
|
||||
class="w-[46px] h-[55px]"
|
||||
/>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@@ -86,11 +86,18 @@
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||
icon="mdi:ticket-outline"
|
||||
label="Tickets client"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
icon="mdi:clock-outline"
|
||||
icon="mdi:calendar-edit-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
@@ -108,24 +115,26 @@
|
||||
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<button
|
||||
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="20"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
||||
<button
|
||||
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="18"
|
||||
/>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
@@ -148,6 +157,7 @@ import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -211,9 +221,9 @@ async function loadRefData() {
|
||||
if (refData.loaded) return
|
||||
const api = useApi()
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
api.get<any>('/users'),
|
||||
api.get<any>('/projects'),
|
||||
api.get<any>('/task_tags'),
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
])
|
||||
refData.users = extractHydraMembers(usersData)
|
||||
refData.projects = extractHydraMembers(projectsData)
|
||||
|
||||
7
frontend/middleware/admin.ts
Normal file
7
frontend/middleware/admin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -23,14 +23,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
],
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
</div>
|
||||
@@ -35,6 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const tabs = [
|
||||
@@ -44,7 +44,6 @@ const tabs = [
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'client-tickets', label: 'Tickets client' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
] as const
|
||||
|
||||
@@ -471,7 +471,7 @@ const lineOptions = {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => `${formatHours(ctx.raw)}`,
|
||||
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -480,7 +480,7 @@ const lineOptions = {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f3f4f6' },
|
||||
ticks: {
|
||||
callback: (value: any) => `${value}h`,
|
||||
callback: (value: number | string) => `${value}h`,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -51,6 +51,9 @@ const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
@@ -214,6 +217,11 @@ async function onDropBacklog(event: DragEvent) {
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
@@ -223,33 +231,80 @@ async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewKanban')"
|
||||
@click="viewMode = 'kanban'"
|
||||
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"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<Icon name="mdi:view-column-outline" size="18" />
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="18" />
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,11 +369,11 @@ onMounted(() => {
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@@ -326,24 +381,27 @@ onMounted(() => {
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,6 +421,7 @@ onMounted(() => {
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
@@ -376,57 +435,31 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="tasks.length"
|
||||
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
|
||||
:task="task"
|
||||
show-project-color
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
@@ -446,6 +479,7 @@ onMounted(() => {
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,10 +53,8 @@ const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
// Extract project ID from IRI
|
||||
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
|
||||
if (match) {
|
||||
const projectId = Number(match[1])
|
||||
const projectId = extractIdFromIri(ticket.project)
|
||||
if (projectId) {
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="min-w-0 flex-1 sm:min-w-[280px]"
|
||||
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="mb-3 flex shrink-0 items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||
@@ -45,7 +45,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
@@ -45,12 +46,21 @@
|
||||
@cancel="selectedFile = null"
|
||||
/>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
|
||||
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
Tickets client
|
||||
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option :value="null">Tous les statuts</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click.stop="onDelete(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
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"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Tickets client' })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(ticket: ClientTicket) {
|
||||
await clientTicketService.remove(ticket.id)
|
||||
await loadTickets()
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
])
|
||||
project.value = p
|
||||
tickets.value = t
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
@@ -11,6 +11,16 @@
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
title="Vue liste"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
|
||||
title="Paramètres du projet"
|
||||
@@ -58,15 +68,33 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@@ -74,30 +102,33 @@
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog -->
|
||||
<div
|
||||
v-if="viewMode === 'kanban'"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||
@dragover.prevent
|
||||
@@ -116,6 +147,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="filteredTasks.length"
|
||||
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(filteredTasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="filteredTasks.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
@@ -189,6 +253,10 @@ const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
@@ -211,6 +279,14 @@ const statusFilterOptions = computed(() =>
|
||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const priorityFilterOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortFilterOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
@@ -225,6 +301,12 @@ const filteredTasks = computed(() => {
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
result = result.filter(t => t.effort?.id === selectedEffortId.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -309,6 +391,52 @@ async function onDropBacklog(event: DragEvent) {
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
||||
@@ -18,8 +18,8 @@
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un projet</span>
|
||||
<span class="sm:hidden">+ Projet</span>
|
||||
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,8 +29,9 @@
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
|
||||
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
|
||||
:class="{ 'opacity-60': project.archived }"
|
||||
:style="projectCardStyle(project.color)"
|
||||
@click="navigateTo(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -40,7 +41,7 @@
|
||||
v-if="project.archived"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
Archivé
|
||||
{{ $t('common.archived') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -59,7 +60,7 @@
|
||||
v-if="projects.length === 0 && !isLoading"
|
||||
class="col-span-full py-12 text-center text-neutral-400"
|
||||
>
|
||||
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
|
||||
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +81,17 @@ import { useClientService } from '~/services/clients'
|
||||
|
||||
useHead({ title: 'Projets' })
|
||||
|
||||
function projectCardStyle(color: string | null) {
|
||||
const hex = (color || '#222783').replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
return {
|
||||
borderRadius: '16px',
|
||||
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
|
||||
|
||||
@@ -13,26 +13,31 @@
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200">
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="px-3 py-1 text-sm font-semibold transition"
|
||||
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
@@ -70,8 +75,6 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateFilter v-model="selectedDateFilter" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +129,7 @@ import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
useHead({ title: 'Suivi des temps' })
|
||||
@@ -308,9 +312,9 @@ async function loadReferenceData() {
|
||||
const api = useApi()
|
||||
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
api.get<any>('/users'),
|
||||
api.get<any>('/projects'),
|
||||
api.get<any>('/task_tags'),
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
@@ -333,6 +337,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
BIN
frontend/public/LOGO_CARRE.png
Normal file
BIN
frontend/public/LOGO_CARRE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -13,6 +13,7 @@ export type Project = {
|
||||
bookstackShelfId: number | null
|
||||
bookstackShelfName: string | null
|
||||
archived: boolean
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
|
||||
@@ -12,7 +12,7 @@ export type UserData = {
|
||||
|
||||
export type UserWrite = {
|
||||
username: string
|
||||
password?: string
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
client?: string | null
|
||||
allowedProjects?: string[]
|
||||
|
||||
11
frontend/utils/iri.ts
Normal file
11
frontend/utils/iri.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Extract the numeric ID from an API Platform IRI string.
|
||||
* Example: "/api/projects/5" → 5
|
||||
*/
|
||||
export function extractIdFromIri(iri: string | null | undefined): number {
|
||||
if (!iri) return 0
|
||||
const lastSlash = iri.lastIndexOf('/')
|
||||
if (lastSlash === -1) return 0
|
||||
const id = Number(iri.substring(lastSlash + 1))
|
||||
return Number.isFinite(id) ? id : 0
|
||||
}
|
||||
@@ -80,6 +80,11 @@ fi
|
||||
|
||||
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||
|
||||
# Ensure var/log exists and is writable by PHP (www-data)
|
||||
mkdir -p "${DEPLOY_DIR}/var/log"
|
||||
chown www-data:www-data "${DEPLOY_DIR}/var/log"
|
||||
chmod 775 "${DEPLOY_DIR}/var/log"
|
||||
|
||||
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
||||
echo "Clearing cache..."
|
||||
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branches',
|
||||
normalizationContext: ['groups' => ['gitea_branch:read']],
|
||||
provider: GiteaBranchProvider::class,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branches',
|
||||
@@ -24,6 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
normalizationContext: ['groups' => ['gitea_branch:read']],
|
||||
provider: GiteaBranchProvider::class,
|
||||
processor: GiteaBranchProcessor::class,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
|
||||
normalizationContext: ['groups' => ['gitea_branch_name:read']],
|
||||
provider: GiteaBranchNameProvider::class,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
|
||||
normalizationContext: ['groups' => ['gitea_pr:read']],
|
||||
provider: GiteaPullRequestProvider::class,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -51,9 +51,12 @@ class TaskDocumentDownloadController extends AbstractController
|
||||
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
|
||||
|
||||
// Inline for images and PDFs, attachment for everything else
|
||||
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
// SVG files are always served as attachment to prevent XSS via embedded JavaScript
|
||||
$disposition = 'image/svg+xml' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_ATTACHMENT
|
||||
: (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT);
|
||||
|
||||
$response->setContentDisposition($disposition, $document->getOriginalName());
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
@@ -91,7 +91,7 @@ class UserAvatarController extends AbstractController
|
||||
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
|
||||
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
|
||||
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
|
||||
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
|
||||
$response->headers->set('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
66
src/Doctrine/ProjectAllowedExtension.php
Normal file
66
src/Doctrine/ProjectAllowedExtension.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
||||
{
|
||||
$this->addWhere($queryBuilder, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
|
||||
{
|
||||
$this->addWhere($queryBuilder, $resourceClass);
|
||||
}
|
||||
|
||||
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
|
||||
{
|
||||
if (Project::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only restrict for ROLE_CLIENT users who are NOT admins
|
||||
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
|
||||
$allowedProjectIds = $user->getAllowedProjects()->map(
|
||||
fn (Project $project) => $project->getId(),
|
||||
)->toArray();
|
||||
|
||||
if ([] === $allowedProjectIds) {
|
||||
$queryBuilder->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
|
||||
->setParameter('allowed_project_ids', $allowedProjectIds)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
|
||||
class BookStackConfiguration
|
||||
@@ -16,6 +17,7 @@ class BookStackConfiguration
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Url]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
|
||||
@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -54,6 +55,27 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
)]
|
||||
class ClientTicket
|
||||
{
|
||||
public const string TYPE_BUG = 'bug';
|
||||
public const string TYPE_IMPROVEMENT = 'improvement';
|
||||
public const string TYPE_OTHER = 'other';
|
||||
|
||||
public const array TYPES = [
|
||||
self::TYPE_BUG,
|
||||
self::TYPE_IMPROVEMENT,
|
||||
self::TYPE_OTHER,
|
||||
];
|
||||
|
||||
public const string STATUS_NEW = 'new';
|
||||
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||
public const string STATUS_DONE = 'done';
|
||||
public const string STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const array STATUSES = [
|
||||
self::STATUS_NEW,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_DONE,
|
||||
self::STATUS_REJECTED,
|
||||
];
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -66,6 +88,7 @@ class ClientTicket
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
||||
#[Assert\Choice(choices: self::TYPES)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
@@ -78,10 +101,12 @@ class ClientTicket
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
||||
#[Assert\Url]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
||||
#[Assert\Choice(choices: self::STATUSES)]
|
||||
private ?string $status = 'new';
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Repository\GiteaConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
|
||||
class GiteaConfiguration
|
||||
@@ -16,6 +17,7 @@ class GiteaConfiguration
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Url]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
|
||||
@@ -13,6 +13,8 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -87,6 +89,15 @@ class Project
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private bool $archived = false;
|
||||
|
||||
/** @var Collection<int, Task> */
|
||||
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
|
||||
private Collection $tasks;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tasks = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -216,4 +227,10 @@ class Project
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['project:read'])]
|
||||
public function getTaskCount(): int
|
||||
{
|
||||
return $this->tasks->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?TaskGroup $group = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'tasks')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?Project $project = null;
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
||||
use App\Repository\TaskBookStackLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
|
||||
@@ -31,6 +32,7 @@ class TaskBookStackLink
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(length: 500)]
|
||||
#[Assert\Url]
|
||||
private string $url;
|
||||
|
||||
#[ORM\Column]
|
||||
|
||||
@@ -61,9 +61,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
private array $roles = [];
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:write'])]
|
||||
private ?string $password = null;
|
||||
|
||||
#[Groups(['user:write'])]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
@@ -224,5 +226,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return '/api/users/'.$this->id.'/avatar';
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $plainPassword): static
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use App\Repository\ClientRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -19,6 +21,7 @@ class CreateProjectTool
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -28,6 +31,10 @@ class CreateProjectTool
|
||||
?string $color = null,
|
||||
?int $clientId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$project = new Project();
|
||||
$project->setName($name);
|
||||
$project->setCode($code);
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -18,10 +20,15 @@ class GetProjectTool
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$project = $this->projectRepository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
|
||||
@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\Project;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
|
||||
class ListProjectsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(bool $archived = false): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(Serializer::project(...), $projects));
|
||||
|
||||
@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -20,6 +22,7 @@ class UpdateProjectTool
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -31,6 +34,10 @@ class UpdateProjectTool
|
||||
?int $clientId = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$project = $this->projectRepository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\ClientRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
|
||||
class ListClientsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientRepository $clientRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($client) => [
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
|
||||
class ListUsersTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($user) => [
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -33,6 +35,7 @@ class CreateTaskTool
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -46,6 +49,10 @@ class CreateTaskTool
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$project = $this->projectRepository->find($projectId);
|
||||
if (null === $project) {
|
||||
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
|
||||
@@ -54,7 +61,6 @@ class CreateTaskTool
|
||||
$task = new Task();
|
||||
$task->setProject($project);
|
||||
$task->setTitle($title);
|
||||
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
||||
|
||||
if (null !== $description) {
|
||||
$task->setDescription($description);
|
||||
@@ -104,8 +110,11 @@ class CreateTaskTool
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->persist($task);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
|
||||
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
||||
$this->entityManager->persist($task);
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
|
||||
return json_encode([
|
||||
'id' => $task->getId(),
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Repository\TaskRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -17,10 +19,15 @@ class DeleteTaskTool
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$task = $this->taskRepository->find($id);
|
||||
|
||||
if (null === $task) {
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -16,10 +18,15 @@ class GetTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$task = $this->taskRepository->find($id);
|
||||
|
||||
if (null === $task) {
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace App\Mcp\Tool\Task;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\TaskRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -25,6 +28,10 @@ class ListTasksTool
|
||||
bool $archived = false,
|
||||
int $limit = 100,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$limit = min($limit, 200);
|
||||
|
||||
$qb = $this->taskRepository->createQueryBuilder('t')
|
||||
|
||||
@@ -15,6 +15,8 @@ use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -30,6 +32,7 @@ class UpdateTaskTool
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -44,6 +47,10 @@ class UpdateTaskTool
|
||||
?array $tagIds = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$task = $this->taskRepository->find($id);
|
||||
|
||||
if (null === $task) {
|
||||
|
||||
@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -19,6 +21,7 @@ class CreateGroupTool
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -27,6 +30,10 @@ class CreateGroupTool
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$project = $this->projectRepository->find($projectId);
|
||||
if (null === $project) {
|
||||
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskEffortRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
|
||||
class ListEffortsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskEffortRepository $taskEffortRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($e) => [
|
||||
|
||||
@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\TaskMeta;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
|
||||
class ListGroupsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(?int $projectId = null, bool $archived = false): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$criteria = ['archived' => $archived];
|
||||
if (null !== $projectId) {
|
||||
$criteria['project'] = $projectId;
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskPriorityRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
|
||||
class ListPrioritiesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskPriorityRepository $taskPriorityRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($p) => [
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
|
||||
class ListStatusesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($s) => [
|
||||
|
||||
@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskTagRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
|
||||
class ListTagsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($t) => [
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Repository\TaskGroupRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -18,6 +20,7 @@ class UpdateGroupTool
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -27,6 +30,10 @@ class UpdateGroupTool
|
||||
?string $color = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$group = $this->taskGroupRepository->find($id);
|
||||
|
||||
if (null === $group) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user