Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa42b6039 | ||
|
|
7b0c2d9fba | ||
|
|
4ce0214ec9 | ||
|
|
43304bebcc | ||
|
|
6668af73a7 | ||
|
|
ff9a6763c3 | ||
|
|
db5b3d39f9 | ||
|
|
1fdc68c66d | ||
|
|
99b664cdd8 | ||
|
|
fd1da75fd7 | ||
|
|
66264e3b8c | ||
|
|
a89fa6a7af | ||
|
|
6862944726 | ||
|
|
e00c33d20b | ||
|
|
1aa72c3b56 | ||
|
|
6a8e406cc5 | ||
|
|
83b42139b2 | ||
|
|
1bdd3883aa | ||
|
|
22c3c3dbd1 | ||
|
|
cb768e0ce1 | ||
|
|
b3d317284e | ||
|
|
5a47adace5 | ||
|
|
75c53632c8 | ||
|
|
97a8afe559 | ||
|
|
bae6d10ece | ||
|
|
a0306bb5b2 | ||
|
|
7e36b6fd49 | ||
|
|
e688c69438 | ||
|
|
e640e715bb | ||
|
|
6784ee9ead | ||
|
|
fc6b6587f9 | ||
|
|
aa38e20c00 | ||
|
|
98370e0478 | ||
|
|
30fb36e668 | ||
|
|
bd01072831 | ||
|
|
df58b09c2e | ||
|
|
26c41f01c0 | ||
|
|
b66caf6824 | ||
|
|
96cbb45e61 | ||
|
|
a8b899f7c4 | ||
|
|
766fddd417 | ||
|
|
1219f3e73e | ||
|
|
ec35a1b2aa |
24
.mcp.json
24
.mcp.json
@@ -1,8 +1,22 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lesstime": {
|
"lesstime": {
|
||||||
"command": "docker",
|
"type": "http",
|
||||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lesstime-local": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
"php-lesstime-fpm",
|
||||||
|
"php",
|
||||||
|
"bin/console",
|
||||||
|
"mcp:server"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -12,10 +12,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink)
|
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
|
src/Enum/ # PHP enums (RecurrenceType)
|
||||||
src/Service/ # Services métier (NotificationService)
|
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||||
|
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||||
@@ -30,10 +31,10 @@ docs/superpowers/ # Plans et specs superpowers
|
|||||||
frontend/ # App Nuxt 4
|
frontend/ # App Nuxt 4
|
||||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||||
frontend/layouts/ # Layouts (default, portal)
|
frontend/layouts/ # Layouts (default, portal)
|
||||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
|
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
|
||||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
|
||||||
frontend/services/dto/ # Types TypeScript
|
frontend/services/dto/ # Types TypeScript
|
||||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||||
```
|
```
|
||||||
@@ -68,6 +69,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
|
|||||||
|
|
||||||
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||||
|
|
||||||
|
### Tags & Versioning
|
||||||
|
|
||||||
|
- La version de l'app est dans `config/version.yaml` (paramètre `app.version`)
|
||||||
|
- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version
|
||||||
|
- Faire un commit séparé de bump : `chore : bump version to v<X.Y.Z>`
|
||||||
|
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
||||||
@@ -97,7 +105,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking
|
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||||
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
|
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
|
||||||
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
|
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
|
||||||
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
||||||
@@ -126,3 +134,5 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||||
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
|
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||||
|
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
|
"sabre/vobject": "^4.5",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/dotenv": "8.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
|
|||||||
235
composer.lock
generated
235
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1a611b09459bb0625242a9a0ea223107",
|
"content-hash": "a764e9ff23705c8d01ee621225395a15",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -3889,6 +3889,239 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/uri",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/uri.git",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.4",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.6",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Uri\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Functions for making sense out of URIs.",
|
||||||
|
"homepage": "http://sabre.io/uri/",
|
||||||
|
"keywords": [
|
||||||
|
"rfc3986",
|
||||||
|
"uri",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/uri/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-uri"
|
||||||
|
},
|
||||||
|
"time": "2024-09-04T15:30:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/vobject",
|
||||||
|
"version": "4.5.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/vobject.git",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||||
|
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
|
||||||
|
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/vobject",
|
||||||
|
"bin/generate_vcards"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "4.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\VObject\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dominik Tobschall",
|
||||||
|
"email": "dominik@fruux.com",
|
||||||
|
"homepage": "http://tobschall.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ivan Enderlin",
|
||||||
|
"email": "ivan.enderlin@hoa-project.net",
|
||||||
|
"homepage": "http://mnt.io/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||||
|
"homepage": "http://sabre.io/vobject/",
|
||||||
|
"keywords": [
|
||||||
|
"availability",
|
||||||
|
"freebusy",
|
||||||
|
"iCalendar",
|
||||||
|
"ical",
|
||||||
|
"ics",
|
||||||
|
"jCal",
|
||||||
|
"jCard",
|
||||||
|
"recurrence",
|
||||||
|
"rfc2425",
|
||||||
|
"rfc2426",
|
||||||
|
"rfc2739",
|
||||||
|
"rfc4770",
|
||||||
|
"rfc5545",
|
||||||
|
"rfc5546",
|
||||||
|
"rfc6321",
|
||||||
|
"rfc6350",
|
||||||
|
"rfc6351",
|
||||||
|
"rfc6474",
|
||||||
|
"rfc6638",
|
||||||
|
"rfc6715",
|
||||||
|
"rfc6868",
|
||||||
|
"vCalendar",
|
||||||
|
"vCard",
|
||||||
|
"vcf",
|
||||||
|
"xCal",
|
||||||
|
"xCard"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/vobject/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-vobject"
|
||||||
|
},
|
||||||
|
"time": "2026-01-12T10:45:19+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/xml",
|
||||||
|
"version": "4.0.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/xml.git",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"lib-libxml": ">=2.6.20",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"sabre/uri": ">=2.0,<4.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/Deserializer/functions.php",
|
||||||
|
"lib/Serializer/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Xml\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Markus Staab",
|
||||||
|
"email": "markus.staab@redaxo.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "sabre/xml is an XML library that you may not hate.",
|
||||||
|
"homepage": "https://sabre.io/xml/",
|
||||||
|
"keywords": [
|
||||||
|
"XMLReader",
|
||||||
|
"XMLWriter",
|
||||||
|
"dom",
|
||||||
|
"xml"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/xml/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-xml"
|
||||||
|
},
|
||||||
|
"time": "2024-09-06T08:00:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.6",
|
"version": "v8.0.6",
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* limiters?: array<string, array{ // Default: []
|
* limiters?: array<string, array{ // Default: []
|
||||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
@@ -685,38 +685,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
* @psalm-type TwigConfig = array{
|
|
||||||
* form_themes?: list<scalar|Param|null>,
|
|
||||||
* globals?: array<string, array{ // Default: []
|
|
||||||
* id?: scalar|Param|null,
|
|
||||||
* type?: scalar|Param|null,
|
|
||||||
* value?: mixed,
|
|
||||||
* }>,
|
|
||||||
* autoescape_service?: scalar|Param|null, // Default: null
|
|
||||||
* autoescape_service_method?: scalar|Param|null, // Default: null
|
|
||||||
* cache?: scalar|Param|null, // Default: true
|
|
||||||
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
|
|
||||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
|
||||||
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
|
|
||||||
* auto_reload?: scalar|Param|null,
|
|
||||||
* optimizations?: int|Param,
|
|
||||||
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
|
||||||
* file_name_pattern?: list<scalar|Param|null>,
|
|
||||||
* paths?: array<string, mixed>,
|
|
||||||
* date?: array{ // The default format options used by the date filter.
|
|
||||||
* format?: scalar|Param|null, // Default: "F j, Y H:i"
|
|
||||||
* interval_format?: scalar|Param|null, // Default: "%d days"
|
|
||||||
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
|
|
||||||
* },
|
|
||||||
* number_format?: array{ // The default format options for the number_format filter.
|
|
||||||
* decimals?: int|Param, // Default: 0
|
|
||||||
* decimal_point?: scalar|Param|null, // Default: "."
|
|
||||||
* thousands_separator?: scalar|Param|null, // Default: ","
|
|
||||||
* },
|
|
||||||
* mailer?: array{
|
|
||||||
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
|
|
||||||
* },
|
|
||||||
* }
|
|
||||||
* @psalm-type SecurityConfig = array{
|
* @psalm-type SecurityConfig = array{
|
||||||
* access_denied_url?: scalar|Param|null, // Default: null
|
* access_denied_url?: scalar|Param|null, // Default: null
|
||||||
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
|
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
|
||||||
@@ -1291,8 +1259,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
|
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
|
||||||
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
|
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
|
||||||
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
|
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
|
||||||
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true
|
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
|
||||||
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true
|
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
|
||||||
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
|
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
|
||||||
* enable_docs?: bool|Param, // Enable the docs // Default: true
|
* enable_docs?: bool|Param, // Enable the docs // Default: true
|
||||||
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
|
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
|
||||||
@@ -1641,12 +1609,154 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type MonologConfig = array{
|
||||||
|
* use_microseconds?: scalar|Param|null, // Default: true
|
||||||
|
* channels?: list<scalar|Param|null>,
|
||||||
|
* handlers?: array<string, array{ // Default: []
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* priority?: scalar|Param|null, // Default: 0
|
||||||
|
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* bubble?: bool|Param, // Default: true
|
||||||
|
* interactive_only?: bool|Param, // Default: false
|
||||||
|
* app_name?: scalar|Param|null, // Default: null
|
||||||
|
* include_stacktraces?: bool|Param, // Default: false
|
||||||
|
* process_psr_3_messages?: array{
|
||||||
|
* enabled?: bool|Param|null, // Default: null
|
||||||
|
* date_format?: scalar|Param|null,
|
||||||
|
* remove_used_context_fields?: bool|Param,
|
||||||
|
* },
|
||||||
|
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
* file_permission?: scalar|Param|null, // Default: null
|
||||||
|
* use_locking?: bool|Param, // Default: false
|
||||||
|
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||||
|
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||||
|
* ident?: scalar|Param|null, // Default: false
|
||||||
|
* logopts?: scalar|Param|null, // Default: 1
|
||||||
|
* facility?: scalar|Param|null, // Default: "user"
|
||||||
|
* max_files?: scalar|Param|null, // Default: 0
|
||||||
|
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* activation_strategy?: scalar|Param|null, // Default: null
|
||||||
|
* stop_buffering?: bool|Param, // Default: true
|
||||||
|
* passthru_level?: scalar|Param|null, // Default: null
|
||||||
|
* excluded_http_codes?: list<array{ // Default: []
|
||||||
|
* code?: scalar|Param|null,
|
||||||
|
* urls?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* accepted_levels?: list<scalar|Param|null>,
|
||||||
|
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||||
|
* buffer_size?: scalar|Param|null, // Default: 0
|
||||||
|
* flush_on_overflow?: bool|Param, // Default: false
|
||||||
|
* handler?: scalar|Param|null,
|
||||||
|
* url?: scalar|Param|null,
|
||||||
|
* exchange?: scalar|Param|null,
|
||||||
|
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||||
|
* channel?: scalar|Param|null, // Default: null
|
||||||
|
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||||
|
* use_attachment?: scalar|Param|null, // Default: true
|
||||||
|
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||||
|
* include_extra?: scalar|Param|null, // Default: false
|
||||||
|
* icon_emoji?: scalar|Param|null, // Default: null
|
||||||
|
* webhook_url?: scalar|Param|null,
|
||||||
|
* exclude_fields?: list<scalar|Param|null>,
|
||||||
|
* token?: scalar|Param|null,
|
||||||
|
* region?: scalar|Param|null,
|
||||||
|
* source?: scalar|Param|null,
|
||||||
|
* use_ssl?: bool|Param, // Default: true
|
||||||
|
* user?: mixed,
|
||||||
|
* title?: scalar|Param|null, // Default: null
|
||||||
|
* host?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 514
|
||||||
|
* config?: list<scalar|Param|null>,
|
||||||
|
* members?: list<scalar|Param|null>,
|
||||||
|
* connection_string?: scalar|Param|null,
|
||||||
|
* timeout?: scalar|Param|null,
|
||||||
|
* time?: scalar|Param|null, // Default: 60
|
||||||
|
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||||
|
* store?: scalar|Param|null, // Default: null
|
||||||
|
* connection_timeout?: scalar|Param|null,
|
||||||
|
* persistent?: bool|Param,
|
||||||
|
* message_type?: scalar|Param|null, // Default: 0
|
||||||
|
* parse_mode?: scalar|Param|null, // Default: null
|
||||||
|
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||||
|
* disable_notification?: bool|Param|null, // Default: null
|
||||||
|
* split_long_messages?: bool|Param, // Default: false
|
||||||
|
* delay_between_messages?: bool|Param, // Default: false
|
||||||
|
* topic?: int|Param, // Default: null
|
||||||
|
* factor?: int|Param, // Default: 1
|
||||||
|
* tags?: list<scalar|Param|null>,
|
||||||
|
* console_formatter_options?: mixed, // Default: []
|
||||||
|
* formatter?: scalar|Param|null,
|
||||||
|
* nested?: bool|Param, // Default: false
|
||||||
|
* publisher?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hostname?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 12201
|
||||||
|
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||||
|
* encoder?: "json"|"compressed_json"|Param,
|
||||||
|
* },
|
||||||
|
* mongodb?: string|array{
|
||||||
|
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||||
|
* uri?: scalar|Param|null,
|
||||||
|
* username?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null,
|
||||||
|
* database?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* collection?: scalar|Param|null, // Default: "logs"
|
||||||
|
* },
|
||||||
|
* elasticsearch?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hosts?: list<scalar|Param|null>,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 9200
|
||||||
|
* transport?: scalar|Param|null, // Default: "Http"
|
||||||
|
* user?: scalar|Param|null, // Default: null
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* index?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* document_type?: scalar|Param|null, // Default: "logs"
|
||||||
|
* ignore_error?: scalar|Param|null, // Default: false
|
||||||
|
* redis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 6379
|
||||||
|
* database?: scalar|Param|null, // Default: 0
|
||||||
|
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||||
|
* },
|
||||||
|
* predis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* },
|
||||||
|
* from_email?: scalar|Param|null,
|
||||||
|
* to_email?: list<scalar|Param|null>,
|
||||||
|
* subject?: scalar|Param|null,
|
||||||
|
* content_type?: scalar|Param|null, // Default: null
|
||||||
|
* headers?: list<scalar|Param|null>,
|
||||||
|
* mailer?: scalar|Param|null, // Default: null
|
||||||
|
* email_prototype?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* method?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* verbosity_levels?: array{
|
||||||
|
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||||
|
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||||
|
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||||
|
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* },
|
||||||
|
* channels?: string|array{
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* elements?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* }>,
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
* services?: ServicesConfig,
|
* services?: ServicesConfig,
|
||||||
* framework?: FrameworkConfig,
|
* framework?: FrameworkConfig,
|
||||||
* twig?: TwigConfig,
|
|
||||||
* security?: SecurityConfig,
|
* security?: SecurityConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
@@ -1654,12 +1764,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
* services?: ServicesConfig,
|
* services?: ServicesConfig,
|
||||||
* framework?: FrameworkConfig,
|
* framework?: FrameworkConfig,
|
||||||
* twig?: TwigConfig,
|
|
||||||
* security?: SecurityConfig,
|
* security?: SecurityConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
@@ -1667,13 +1777,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
* services?: ServicesConfig,
|
* services?: ServicesConfig,
|
||||||
* framework?: FrameworkConfig,
|
* framework?: FrameworkConfig,
|
||||||
* twig?: TwigConfig,
|
|
||||||
* security?: SecurityConfig,
|
* security?: SecurityConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
@@ -1681,13 +1791,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
* services?: ServicesConfig,
|
* services?: ServicesConfig,
|
||||||
* framework?: FrameworkConfig,
|
* framework?: FrameworkConfig,
|
||||||
* twig?: TwigConfig,
|
|
||||||
* security?: SecurityConfig,
|
* security?: SecurityConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
@@ -1695,6 +1805,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.1'
|
app.version: '0.3.7'
|
||||||
|
|||||||
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
File diff suppressed because it is too large
Load Diff
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# Intégration Calendrier Zimbra CalDAV
|
||||||
|
|
||||||
|
**Date** : 2026-03-19
|
||||||
|
**Statut** : Validé
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.
|
||||||
|
|
||||||
|
## Principes
|
||||||
|
|
||||||
|
- **Push uniquement** : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
|
||||||
|
- **Opt-in** : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
|
||||||
|
- **Sync synchrone** : les appels CalDAV se font au moment de l'action, timeout 5s
|
||||||
|
- **Configuration globale** : un seul compte Zimbra admin pour toute l'instance
|
||||||
|
- **Calendrier d'équipe** : toutes les tâches sync vont dans le même calendrier
|
||||||
|
|
||||||
|
## Modèle de données
|
||||||
|
|
||||||
|
### Nouveaux champs sur `Task`
|
||||||
|
|
||||||
|
| Champ | Type | Nullable | Default | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `scheduledStart` | `DateTimeImmutable` | oui | `null` | Début du créneau planifié |
|
||||||
|
| `scheduledEnd` | `DateTimeImmutable` | oui | `null` | Fin du créneau planifié |
|
||||||
|
| `deadline` | `DateTimeImmutable` | oui | `null` | Date d'échéance |
|
||||||
|
| `syncToCalendar` | `bool` | non | `false` | Opt-in pour la sync Zimbra |
|
||||||
|
| `calendarEventUid` | `string` | oui | `null` | UID du VEVENT dans Zimbra |
|
||||||
|
| `calendarTodoUid` | `string` | oui | `null` | UID du VTODO dans Zimbra |
|
||||||
|
| `calendarSyncError` | `string` | oui | `null` | Dernière erreur de sync CalDAV (null = OK) |
|
||||||
|
|
||||||
|
#### Règles de validation
|
||||||
|
|
||||||
|
- `scheduledEnd` requiert `scheduledStart` (et vice versa) — les deux ou aucun
|
||||||
|
- `scheduledEnd` doit être après `scheduledStart`
|
||||||
|
- `syncToCalendar = true` sans aucune date → ignoré silencieusement (pas de sync)
|
||||||
|
- `deadline` est indépendant des dates planifiées (peut exister seul)
|
||||||
|
|
||||||
|
### Nouvelle entité `TaskRecurrence`
|
||||||
|
|
||||||
|
| Champ | Type | Nullable | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | `int` | non | PK auto-increment |
|
||||||
|
| `type` | `RecurrenceType` (PHP enum) | non | Enum backed string : `daily`, `weekly`, `monthly`, `yearly` |
|
||||||
|
| `interval` | `int` | non | Tous les X (jours/semaines/mois/ans) |
|
||||||
|
| `daysOfWeek` | `json` | oui | Jours de la semaine pour hebdo, ex: `["monday","wednesday"]` |
|
||||||
|
| `dayOfMonth` | `int` | oui | Jour du mois pour mensuel, ex: `15` |
|
||||||
|
| `weekOfMonth` | `int` | oui | Semaine du mois, ex: `1` pour "le 1er X du mois" |
|
||||||
|
| `endDate` | `Date` | oui | Fin de la récurrence (null = infini) |
|
||||||
|
| `maxOccurrences` | `int` | oui | Nombre max d'occurrences (alternatif à endDate) |
|
||||||
|
| `occurrenceCount` | `int` | non | Compteur d'occurrences créées (default 0) |
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
|
||||||
|
- `Task.recurrence` → `ManyToOne` vers `TaskRecurrence` (nullable)
|
||||||
|
- `TaskRecurrence.tasks` → `OneToMany` vers `Task`
|
||||||
|
|
||||||
|
### Nouvelle entité `ZimbraConfiguration`
|
||||||
|
|
||||||
|
| Champ | Type | Nullable | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | `int` | non | PK auto-increment |
|
||||||
|
| `serverUrl` | `string` | non | URL CalDAV Zimbra |
|
||||||
|
| `username` | `string` | non | Compte Zimbra |
|
||||||
|
| `encryptedPassword` | `string` | non | Mot de passe chiffré via `TokenEncryptor` (même pattern que `GiteaConfiguration`) |
|
||||||
|
| `calendarPath` | `string` | non | Chemin complet du calendrier, ex: `/dav/user@domain.com/Calendar/` |
|
||||||
|
| `enabled` | `bool` | non | Activer/désactiver la sync (default false) |
|
||||||
|
|
||||||
|
## Service CalDAV
|
||||||
|
|
||||||
|
### `CalDavService`
|
||||||
|
|
||||||
|
Dépendances : `sabre/vobject` pour la génération ICS, requêtes HTTP via `Symfony\Contracts\HttpClient`.
|
||||||
|
|
||||||
|
Le service utilise la `ZimbraConfiguration` pour construire l'URL CalDAV complète : `{serverUrl}{calendarPath}{uid}.ics`. Le mot de passe est déchiffré via `TokenEncryptor` avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.
|
||||||
|
|
||||||
|
#### Méthodes
|
||||||
|
|
||||||
|
- `createEvent(Task): string` — crée un VEVENT (créneau planifié), retourne l'UID
|
||||||
|
- `createTodo(Task): string` — crée un VTODO (deadline), retourne l'UID
|
||||||
|
- `updateEvent(Task): void` — met à jour le VEVENT existant
|
||||||
|
- `updateTodo(Task): void` — met à jour le VTODO existant
|
||||||
|
- `deleteEvent(string $uid): void` — supprime le VEVENT par UID
|
||||||
|
- `deleteTodo(string $uid): void` — supprime le VTODO par UID
|
||||||
|
- `testConnection(): bool` — teste la connexion CalDAV
|
||||||
|
|
||||||
|
#### Format ICS
|
||||||
|
|
||||||
|
Toutes les dates sont envoyées en **UTC** (suffixe `Z`). Les composants sont wrappés dans un document iCalendar complet :
|
||||||
|
|
||||||
|
**VEVENT (créneau planifié)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Lesstime//CalDAV//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:{calendarEventUid}
|
||||||
|
SUMMARY:[PROJET-NUM] Titre de la tâche
|
||||||
|
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
|
||||||
|
DTEND:{scheduledEnd en UTC}
|
||||||
|
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||||
|
RRULE:{rrule si récurrence}
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
```
|
||||||
|
|
||||||
|
**VTODO (deadline)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Lesstime//CalDAV//EN
|
||||||
|
BEGIN:VTODO
|
||||||
|
UID:{calendarTodoUid}
|
||||||
|
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
|
||||||
|
DUE:{deadline en UTC}
|
||||||
|
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||||
|
END:VTODO
|
||||||
|
END:VCALENDAR
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.
|
||||||
|
|
||||||
|
## Logique de sync
|
||||||
|
|
||||||
|
### Déclenchement
|
||||||
|
|
||||||
|
Un **API Platform State Processor** (`TaskCalendarProcessor`) qui décore le persist/remove processor. La sync CalDAV est appelée **après** le flush en BDD, jamais pendant la transaction. Cela garantit :
|
||||||
|
- La tâche est sauvegardée même si Zimbra est down
|
||||||
|
- Pas de blocage de transaction DB par les appels HTTP
|
||||||
|
|
||||||
|
Pour les **MCP tools**, le `CalDavService` doit être appelé explicitement après le `flush()` dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).
|
||||||
|
|
||||||
|
### Matrice d'actions
|
||||||
|
|
||||||
|
| Action Lesstime | Effet CalDAV |
|
||||||
|
|---|---|
|
||||||
|
| Tâche créée/modifiée avec `syncToCalendar=true` et dates renseignées | Crée ou met à jour VEVENT + VTODO |
|
||||||
|
| `syncToCalendar` décoché | Supprime VEVENT + VTODO si existants |
|
||||||
|
| Tâche supprimée | Supprime VEVENT + VTODO si existants |
|
||||||
|
| Tâche récurrente passe en `isFinal` | Tâche archivée (`archived=true`), événements **conservés** dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent |
|
||||||
|
| Dates retirées | Supprime les events correspondants |
|
||||||
|
|
||||||
|
### Gestion des erreurs
|
||||||
|
|
||||||
|
- Timeout CalDAV : 5 secondes
|
||||||
|
- En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
|
||||||
|
- L'erreur est persistée dans `calendarSyncError` (visible dans l'UI comme indicateur rouge)
|
||||||
|
- Les UIDs CalDAV restent `null` si la création a échoué
|
||||||
|
- En cas de succès après un échec précédent, `calendarSyncError` est remis à `null`
|
||||||
|
|
||||||
|
## Tâches récurrentes
|
||||||
|
|
||||||
|
### Comportement
|
||||||
|
|
||||||
|
1. L'utilisateur crée une tâche avec récurrence dans Lesstime
|
||||||
|
2. **Zimbra** : un seul VEVENT avec `RRULE` est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
|
||||||
|
3. **Lesstime** : une seule tâche existe à la fois
|
||||||
|
4. Quand la tâche passe en statut `isFinal` :
|
||||||
|
- La tâche est archivée automatiquement (`archived = true`)
|
||||||
|
- Les événements Zimbra sont **conservés** (historique)
|
||||||
|
- Les `calendarEventUid` et `calendarTodoUid` de la tâche archivée sont **vidés** (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée
|
||||||
|
- Une nouvelle tâche est créée avec :
|
||||||
|
- Même titre, description, assigné, tags, projet, groupe, effort, priorité
|
||||||
|
- Nouveau `number` généré via `findMaxNumberByProjectForUpdate` (même pattern transactionnel que `TaskNumberProcessor`)
|
||||||
|
- Statut réinitialisé au premier statut (position la plus basse)
|
||||||
|
- Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
|
||||||
|
- `calendarEventUid` pointant vers le même VEVENT récurrent
|
||||||
|
- Nouveau `calendarTodoUid` (nouvelle deadline)
|
||||||
|
- `occurrenceCount` incrémenté sur `TaskRecurrence` (avec lock optimiste `@ORM\Version` pour éviter les doublons en cas de concurrence)
|
||||||
|
5. Si `maxOccurrences` ou `endDate` atteint, la récurrence s'arrête (pas de nouvelle tâche créée)
|
||||||
|
|
||||||
|
### Calcul de la prochaine date
|
||||||
|
|
||||||
|
La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :
|
||||||
|
|
||||||
|
- **Daily** : `scheduledStart + interval jours`
|
||||||
|
- **Weekly** : prochain jour de `daysOfWeek` à partir de `scheduledStart + interval semaines`
|
||||||
|
- **Monthly** : même `dayOfMonth` ou même `weekOfMonth`+jour, mois `+ interval`
|
||||||
|
- **Yearly** : même date, année `+ interval`
|
||||||
|
|
||||||
|
La durée du créneau (`scheduledEnd - scheduledStart`) est conservée.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Onglet "Planification" dans TaskModal
|
||||||
|
|
||||||
|
La modale tâche existante aura 2 onglets :
|
||||||
|
|
||||||
|
**Onglet "Détails"** (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe
|
||||||
|
|
||||||
|
**Onglet "Planification"** (nouveau) :
|
||||||
|
|
||||||
|
#### Bloc Dates
|
||||||
|
- Date planifiée début (`datetime-local` picker)
|
||||||
|
- Date planifiée fin (`datetime-local` picker)
|
||||||
|
- Deadline (`date` picker)
|
||||||
|
|
||||||
|
#### Bloc Calendrier
|
||||||
|
- Checkbox "Envoyer au calendrier" (décoché par défaut)
|
||||||
|
- Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)
|
||||||
|
|
||||||
|
#### Bloc Récurrence
|
||||||
|
- Toggle "Tâche récurrente"
|
||||||
|
- Si activé :
|
||||||
|
- Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
|
||||||
|
- Intervalle : "Tous les X ..." (input number)
|
||||||
|
- Conditionnel selon le type :
|
||||||
|
- Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
|
||||||
|
- Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
|
||||||
|
- Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)
|
||||||
|
|
||||||
|
### Affichage des dates
|
||||||
|
|
||||||
|
**Cartes Kanban (`TaskCard`)** :
|
||||||
|
- Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
|
||||||
|
- Icône calendrier si `syncToCalendar` activé
|
||||||
|
- Icône récurrence si tâche récurrente
|
||||||
|
|
||||||
|
**Vue liste (`TaskListItem`)** :
|
||||||
|
- Colonne "Planifié" (date début)
|
||||||
|
- Colonne "Deadline"
|
||||||
|
- Icône récurrence si tâche récurrente
|
||||||
|
|
||||||
|
**Page "Mes tâches"** :
|
||||||
|
- Même affichage que la vue liste
|
||||||
|
- Tri possible par deadline ou date planifiée
|
||||||
|
|
||||||
|
### Page Admin — Configuration Zimbra
|
||||||
|
|
||||||
|
Nouveau bloc dans la page admin existante :
|
||||||
|
|
||||||
|
- URL du serveur CalDAV (input text)
|
||||||
|
- Nom d'utilisateur (input text)
|
||||||
|
- Mot de passe (input password)
|
||||||
|
- Chemin du calendrier (input text)
|
||||||
|
- Toggle activer/désactiver
|
||||||
|
- Bouton "Tester la connexion" (toast succès/erreur)
|
||||||
|
|
||||||
|
Accessible uniquement `ROLE_ADMIN`.
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
### Mise à jour des tools existants
|
||||||
|
|
||||||
|
`create-task` et `update-task` : nouveaux paramètres optionnels :
|
||||||
|
- `scheduledStart` (string datetime ISO)
|
||||||
|
- `scheduledEnd` (string datetime ISO)
|
||||||
|
- `deadline` (string datetime ISO)
|
||||||
|
- `syncToCalendar` (bool)
|
||||||
|
|
||||||
|
### Nouveaux tools
|
||||||
|
|
||||||
|
- `create-task-recurrence` — paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?
|
||||||
|
- `update-task-recurrence` — paramètres : recurrenceId, + mêmes champs optionnels
|
||||||
|
- `delete-task-recurrence` — paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant
|
||||||
|
|
||||||
|
## API Filters
|
||||||
|
|
||||||
|
Ajouter sur `Task` les filtres API Platform suivants :
|
||||||
|
- `DateFilter` sur `scheduledStart`, `scheduledEnd`, `deadline` (pour le tri et filtrage par plage de dates)
|
||||||
|
- `BooleanFilter` sur `syncToCalendar`
|
||||||
|
- `OrderFilter` sur `scheduledStart`, `deadline`
|
||||||
|
|
||||||
|
### Valeurs stockées en JSON (i18n)
|
||||||
|
|
||||||
|
Les `daysOfWeek` dans `TaskRecurrence` sont stockés en anglais (`monday`, `tuesday`...) — les labels traduits sont gérés uniquement côté frontend via i18n.
|
||||||
|
|
||||||
|
## Dépendances PHP
|
||||||
|
|
||||||
|
- `sabre/vobject` — génération/parsing ICS (VEVENT, VTODO, RRULE)
|
||||||
|
- `symfony/http-client` — requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)
|
||||||
|
|
||||||
|
## Limitations connues
|
||||||
|
|
||||||
|
- Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
|
||||||
|
- Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.
|
||||||
248
frontend/assets/css/dark.css
Normal file
248
frontend/assets/css/dark.css
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
* Dark theme overrides
|
||||||
|
* Automatically applied when <html class="dark"> is set.
|
||||||
|
* Overrides existing Tailwind utilities so components need zero changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Backgrounds ── */
|
||||||
|
|
||||||
|
.dark .bg-white {
|
||||||
|
background-color: #1e1f2b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-tertiary-500 {
|
||||||
|
background-color: #262838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-neutral-50 {
|
||||||
|
background-color: #262838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-neutral-100 {
|
||||||
|
background-color: #2e3045 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-neutral-200 {
|
||||||
|
background-color: #363952 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hover backgrounds ── */
|
||||||
|
|
||||||
|
.dark .hover\:bg-neutral-50:hover {
|
||||||
|
background-color: #2e3045 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-neutral-100:hover {
|
||||||
|
background-color: #363952 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-neutral-200:hover {
|
||||||
|
background-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-neutral-300:hover {
|
||||||
|
background-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:shadow-md:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text ── */
|
||||||
|
|
||||||
|
.dark .text-neutral-900 {
|
||||||
|
color: #e5e5e5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-800 {
|
||||||
|
color: #d4d4d8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-700 {
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-600 {
|
||||||
|
color: #8b8b9a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-500 {
|
||||||
|
color: #71717a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-400 {
|
||||||
|
color: #606070 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-neutral-300 {
|
||||||
|
color: #52525b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hover text ── */
|
||||||
|
|
||||||
|
.dark .hover\:text-neutral-700:hover {
|
||||||
|
color: #d4d4d8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:text-neutral-600:hover {
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Borders ── */
|
||||||
|
|
||||||
|
.dark .border-neutral-200 {
|
||||||
|
border-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-neutral-100 {
|
||||||
|
border-color: #2e3045 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-neutral-300 {
|
||||||
|
border-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:border-neutral-300:hover {
|
||||||
|
border-color: #4a4d64 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:border-neutral-400:hover {
|
||||||
|
border-color: #4a4d64 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ring ── */
|
||||||
|
|
||||||
|
.dark .ring-black\/5 {
|
||||||
|
--tw-ring-color: rgb(255 255 255 / 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Specific component overrides ── */
|
||||||
|
|
||||||
|
/* Modal header bg */
|
||||||
|
.dark .bg-neutral-50\/80 {
|
||||||
|
background-color: rgb(38 40 56 / 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar collapse button */
|
||||||
|
.dark .shadow-sm {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User dropdown */
|
||||||
|
.dark .shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms: inputs, selects, textareas */
|
||||||
|
.dark input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.dark textarea,
|
||||||
|
.dark select {
|
||||||
|
background-color: #1e1f2b !important;
|
||||||
|
color: #e5e5e5 !important;
|
||||||
|
border-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input:not([type="checkbox"]):not([type="radio"])::placeholder,
|
||||||
|
.dark textarea::placeholder {
|
||||||
|
color: #606070 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
.dark textarea:focus,
|
||||||
|
.dark select:focus {
|
||||||
|
border-color: #222783 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.dark label {
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Malio Layer UI components ── */
|
||||||
|
|
||||||
|
/* MalioSelect: floating label has hardcoded background: white */
|
||||||
|
.dark .floating-label {
|
||||||
|
background: #1e1f2b !important;
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MalioSelect: text-black used for selected value and options */
|
||||||
|
.dark .text-black {
|
||||||
|
color: #e5e5e5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-black\/60 {
|
||||||
|
color: #71717a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-black\/40 {
|
||||||
|
color: #606070 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MalioSelect: border-black used when option is selected */
|
||||||
|
.dark .border-black {
|
||||||
|
border-color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MalioSelect: border-m-muted default border */
|
||||||
|
.dark .border-m-muted {
|
||||||
|
border-color: #3a3d54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MalioSelect: dropdown option hover background */
|
||||||
|
.dark .bg-m-muted\/10 {
|
||||||
|
background-color: rgb(160 174 192 / 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MalioSelect: dropdown shadow */
|
||||||
|
.dark .shadow-2xl {
|
||||||
|
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox/radio hardcoded black borders */
|
||||||
|
.dark .inp-cbx + .cbx svg {
|
||||||
|
stroke: #e5e5e5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .inp-cbx + .cbx {
|
||||||
|
border-color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red/colored backgrounds for buttons */
|
||||||
|
.dark .bg-red-50 {
|
||||||
|
background-color: rgb(127 29 29 / 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-red-100:hover {
|
||||||
|
background-color: rgb(127 29 29 / 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-blue-50 {
|
||||||
|
background-color: rgb(30 58 138 / 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datetime/date input color-scheme for dark mode */
|
||||||
|
.dark input[type="datetime-local"],
|
||||||
|
.dark input[type="date"],
|
||||||
|
.dark input[type="time"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.dark ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1e1f2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3d54;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4a4d64;
|
||||||
|
}
|
||||||
124
frontend/components/admin/AdminZimbraTab.vue
Normal file
124
frontend/components/admin/AdminZimbraTab.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('zimbra.settings.title') }}</h2>
|
||||||
|
|
||||||
|
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.serverUrl"
|
||||||
|
:label="$t('zimbra.settings.serverUrl')"
|
||||||
|
:placeholder="$t('zimbra.settings.serverUrlPlaceholder')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.username"
|
||||||
|
:label="$t('zimbra.settings.username')"
|
||||||
|
:placeholder="$t('zimbra.settings.usernamePlaceholder')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.calendarPath"
|
||||||
|
:label="$t('zimbra.settings.calendarPath')"
|
||||||
|
:placeholder="$t('zimbra.settings.calendarPathPlaceholder')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.password"
|
||||||
|
:label="$t('zimbra.settings.password')"
|
||||||
|
input-class="w-full"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||||
|
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||||
|
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
{{ $t('zimbra.settings.save') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||||
|
:disabled="isTesting"
|
||||||
|
@click="handleTest"
|
||||||
|
>
|
||||||
|
{{ $t('zimbra.settings.testConnection') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
|
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useZimbraService } from '~/services/zimbra'
|
||||||
|
|
||||||
|
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
serverUrl: '',
|
||||||
|
username: '',
|
||||||
|
calendarPath: '',
|
||||||
|
password: '',
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPassword = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const isTesting = ref(false)
|
||||||
|
const testResult = ref<boolean | null>(null)
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const settings = await getSettings()
|
||||||
|
form.serverUrl = settings.serverUrl ?? ''
|
||||||
|
form.username = settings.username ?? ''
|
||||||
|
form.calendarPath = settings.calendarPath ?? ''
|
||||||
|
form.enabled = settings.enabled
|
||||||
|
hasPassword.value = settings.hasPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
const result = await saveSettings({
|
||||||
|
serverUrl: form.serverUrl.trim() || null,
|
||||||
|
username: form.username.trim() || null,
|
||||||
|
calendarPath: form.calendarPath.trim() || null,
|
||||||
|
password: form.password || null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
})
|
||||||
|
hasPassword.value = result.hasPassword
|
||||||
|
form.password = ''
|
||||||
|
testResult.value = null
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
isTesting.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const result = await testConnection()
|
||||||
|
testResult.value = result.success
|
||||||
|
} catch {
|
||||||
|
testResult.value = false
|
||||||
|
} finally {
|
||||||
|
isTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-1">
|
<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
|
<Icon
|
||||||
v-if="task.clientTicket"
|
v-if="task.clientTicket"
|
||||||
name="heroicons:user-circle"
|
name="heroicons:user-circle"
|
||||||
@@ -44,6 +54,29 @@
|
|||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Deadline badge -->
|
||||||
|
<span
|
||||||
|
v-if="task.deadline"
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: deadlineColor }"
|
||||||
|
:title="task.deadline"
|
||||||
|
>
|
||||||
|
{{ formatDeadline(task.deadline) }}
|
||||||
|
</span>
|
||||||
|
<!-- Calendar sync icon -->
|
||||||
|
<Icon
|
||||||
|
v-if="task.syncToCalendar"
|
||||||
|
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||||
|
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||||
|
size="14"
|
||||||
|
/>
|
||||||
|
<!-- Recurrence icon -->
|
||||||
|
<Icon
|
||||||
|
v-if="task.recurrence"
|
||||||
|
name="mdi:repeat"
|
||||||
|
class="text-blue-500"
|
||||||
|
size="14"
|
||||||
|
/>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
:user="task.assignee"
|
:user="task.assignee"
|
||||||
@@ -63,9 +96,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
}>()
|
showProjectColor?: boolean
|
||||||
|
}>(), {
|
||||||
|
showProjectColor: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click'): void
|
(e: 'click'): void
|
||||||
@@ -87,6 +123,18 @@ function onPlay() {
|
|||||||
timerStore.startFromTask(props.task)
|
timerStore.startFromTask(props.task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deadlineColor = computed(() => {
|
||||||
|
if (!props.task.deadline) return ''
|
||||||
|
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||||
|
if (daysLeft < 0) return '#DC2626'
|
||||||
|
if (daysLeft < 2) return '#F59E0B'
|
||||||
|
return '#9CA3AF'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDeadline(d: string): string {
|
||||||
|
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
function onDragStart(event: DragEvent) {
|
function onDragStart(event: DragEvent) {
|
||||||
event.dataTransfer!.effectAllowed = 'move'
|
event.dataTransfer!.effectAllowed = 'move'
|
||||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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"
|
|
||||||
label="Titre"
|
|
||||||
input-class="w-full"
|
|
||||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
|
||||||
@blur="touched.title = true"
|
|
||||||
/>
|
|
||||||
<MalioInputTextArea
|
|
||||||
v-model="form.description"
|
|
||||||
label="Description"
|
|
||||||
:size="3"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.statusId"
|
|
||||||
:options="statusOptions"
|
|
||||||
label="Statut"
|
|
||||||
empty-option-label="Aucun statut"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.effortId"
|
|
||||||
:options="effortOptions"
|
|
||||||
label="Effort"
|
|
||||||
empty-option-label="Aucun effort"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.priorityId"
|
|
||||||
:options="priorityOptions"
|
|
||||||
label="Priorité"
|
|
||||||
empty-option-label="Aucune priorité"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.assigneeId"
|
|
||||||
:options="userOptions"
|
|
||||||
label="User"
|
|
||||||
empty-option-label="Aucun utilisateur"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.groupId"
|
|
||||||
:options="groupOptions"
|
|
||||||
label="Groupe"
|
|
||||||
empty-option-label="Aucun groupe"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-for="tag in tags"
|
|
||||||
:key="tag.id"
|
|
||||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
|
||||||
:class="form.tagIds.includes(tag.id)
|
|
||||||
? 'text-white'
|
|
||||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
|
||||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="hidden"
|
|
||||||
:value="tag.id"
|
|
||||||
:checked="form.tagIds.includes(tag.id)"
|
|
||||||
@change="toggleTag(tag.id)"
|
|
||||||
/>
|
|
||||||
{{ tag.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
|
||||||
<button
|
|
||||||
v-if="isEditing"
|
|
||||||
type="button"
|
|
||||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="confirmDeleteOpen = true"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
v-if="canArchive"
|
|
||||||
type="button"
|
|
||||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleArchive"
|
|
||||||
>
|
|
||||||
{{ $t('archive.archiveButton') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canUnarchive"
|
|
||||||
type="button"
|
|
||||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleUnarchive"
|
|
||||||
>
|
|
||||||
{{ $t('archive.unarchiveButton') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ConfirmDeleteTaskModal
|
|
||||||
v-model="confirmDeleteOpen"
|
|
||||||
@confirm="handleDelete"
|
|
||||||
/>
|
|
||||||
</AppDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
|
||||||
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 { TaskTag } from '~/services/dto/task-tag'
|
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
|
||||||
import { useTaskService } from '~/services/tasks'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
task: Task | null
|
|
||||||
projectId: number
|
|
||||||
statuses: TaskStatus[]
|
|
||||||
efforts: TaskEffort[]
|
|
||||||
priorities: TaskPriority[]
|
|
||||||
tags: TaskTag[]
|
|
||||||
groups: TaskGroup[]
|
|
||||||
users: UserData[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.task)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const confirmDeleteOpen = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
statusId: null as number | null,
|
|
||||||
effortId: null as number | null,
|
|
||||||
priorityId: null as number | null,
|
|
||||||
assigneeId: null as number | null,
|
|
||||||
groupId: null as number | null,
|
|
||||||
tagIds: [] as number[],
|
|
||||||
})
|
|
||||||
|
|
||||||
const touched = reactive({
|
|
||||||
title: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
|
||||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const effortOptions = computed(() =>
|
|
||||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const priorityOptions = computed(() =>
|
|
||||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
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 canArchive = computed(() => {
|
|
||||||
if (!isEditing.value || !props.task) return false
|
|
||||||
if (props.task.archived) return false
|
|
||||||
const status = props.statuses.find(s => s.id === props.task?.status?.id)
|
|
||||||
return !!status?.isFinal
|
|
||||||
})
|
|
||||||
|
|
||||||
const canUnarchive = computed(() => {
|
|
||||||
return isEditing.value && !!props.task?.archived
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleTag(id: number) {
|
|
||||||
const idx = form.tagIds.indexOf(id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
form.tagIds.splice(idx, 1)
|
|
||||||
} else {
|
|
||||||
form.tagIds.push(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateForm(task: Task | null) {
|
|
||||||
if (task) {
|
|
||||||
form.title = task.title ?? ''
|
|
||||||
form.description = task.description ?? ''
|
|
||||||
form.statusId = task.status?.id ?? null
|
|
||||||
form.effortId = task.effort?.id ?? null
|
|
||||||
form.priorityId = task.priority?.id ?? null
|
|
||||||
form.assigneeId = task.assignee?.id ?? null
|
|
||||||
form.groupId = task.group?.id ?? null
|
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
|
||||||
} else {
|
|
||||||
form.title = ''
|
|
||||||
form.description = ''
|
|
||||||
form.statusId = null
|
|
||||||
form.effortId = null
|
|
||||||
form.priorityId = null
|
|
||||||
form.assigneeId = null
|
|
||||||
form.groupId = null
|
|
||||||
form.tagIds = []
|
|
||||||
}
|
|
||||||
touched.title = false
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
populateForm(props.task)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.task, (task) => {
|
|
||||||
if (props.modelValue) {
|
|
||||||
populateForm(task)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { create, update, remove } = useTaskService()
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!props.task) return
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await remove(props.task.id)
|
|
||||||
confirmDeleteOpen.value = false
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleArchive() {
|
|
||||||
if (!props.task) return
|
|
||||||
const timerStore = useTimerStore()
|
|
||||||
if (timerStore.activeEntry?.task) {
|
|
||||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
|
||||||
? timerStore.activeEntry.task
|
|
||||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
|
||||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
|
||||||
await timerStore.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await update(props.task.id, { archived: true })
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUnarchive() {
|
|
||||||
if (!props.task) return
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await update(props.task.id, { archived: false })
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
touched.title = true
|
|
||||||
if (!form.title.trim()) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const payload: TaskWrite = {
|
|
||||||
title: form.title.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
|
|
||||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
|
||||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
|
||||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
|
||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
|
||||||
project: `/api/projects/${props.projectId}`,
|
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value && props.task) {
|
|
||||||
await update(props.task.id, payload)
|
|
||||||
} else {
|
|
||||||
await create(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
143
frontend/components/task/TaskListItem.vue
Normal file
143
frontend/components/task/TaskListItem.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<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 + deadline/calendar/recurrence -->
|
||||||
|
<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>
|
||||||
|
<!-- Deadline badge -->
|
||||||
|
<span
|
||||||
|
v-if="task.deadline"
|
||||||
|
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: deadlineColor }"
|
||||||
|
:title="task.deadline"
|
||||||
|
>
|
||||||
|
{{ formatDeadline(task.deadline) }}
|
||||||
|
</span>
|
||||||
|
<!-- Calendar sync icon -->
|
||||||
|
<Icon
|
||||||
|
v-if="task.syncToCalendar"
|
||||||
|
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||||
|
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||||
|
size="13"
|
||||||
|
/>
|
||||||
|
<!-- Recurrence icon -->
|
||||||
|
<Icon
|
||||||
|
v-if="task.recurrence"
|
||||||
|
name="mdi:repeat"
|
||||||
|
class="text-blue-500"
|
||||||
|
size="13"
|
||||||
|
/>
|
||||||
|
</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 deadlineColor = computed(() => {
|
||||||
|
if (!props.task.deadline) return ''
|
||||||
|
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||||
|
if (daysLeft < 0) return '#DC2626'
|
||||||
|
if (daysLeft < 2) return '#F59E0B'
|
||||||
|
return '#9CA3AF'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDeadline(d: string): string {
|
||||||
|
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -56,6 +56,25 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||||
|
<nav class="flex gap-6">
|
||||||
|
<button
|
||||||
|
v-for="tab in ['details', 'planning']"
|
||||||
|
:key="tab"
|
||||||
|
type="button"
|
||||||
|
class="px-1 pb-3 text-sm font-semibold transition"
|
||||||
|
:class="activeTab === tab
|
||||||
|
? 'border-b-2 border-primary-500 text-primary-500'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
|
@click="activeTab = tab as 'details' | 'planning'"
|
||||||
|
>
|
||||||
|
{{ $t(`tasks.${tab}Tab`) }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'details'">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
@@ -160,8 +179,6 @@
|
|||||||
resize="vertical"
|
resize="vertical"
|
||||||
:min-resize-height="140"
|
:min-resize-height="140"
|
||||||
:max-resize-height="500"
|
:max-resize-height="500"
|
||||||
min-resize-width="100%"
|
|
||||||
max-resize-width="100%"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,6 +218,199 @@
|
|||||||
v-if="hasBookStack && isEditing && task"
|
v-if="hasBookStack && isEditing && task"
|
||||||
:task-id="task.id"
|
:task-id="task.id"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'planning'" class="space-y-6">
|
||||||
|
<!-- Dates section -->
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledStart') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.scheduledStart"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledEnd') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.scheduledEnd"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="sm:w-1/2">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.deadline') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.deadline"
|
||||||
|
type="date"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar sync -->
|
||||||
|
<div class="rounded-lg border border-neutral-200 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.syncToCalendar"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
|
||||||
|
<Icon
|
||||||
|
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
|
||||||
|
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
|
||||||
|
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recurrence -->
|
||||||
|
<div class="rounded-lg border border-neutral-200 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.isRecurring"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="form.isRecurring" class="mt-4 space-y-4">
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
|
||||||
|
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||||
|
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
|
||||||
|
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
|
||||||
|
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
|
||||||
|
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interval -->
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.recurrenceInterval"
|
||||||
|
:label="$t('tasks.planning.interval')"
|
||||||
|
type="number"
|
||||||
|
input-class="w-full sm:w-1/3"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Weekly: days of week -->
|
||||||
|
<div v-if="form.recurrenceType === 'weekly'">
|
||||||
|
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="day in weekDays"
|
||||||
|
:key="day.value"
|
||||||
|
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||||
|
:class="form.recurrenceDaysOfWeek.includes(day.value)
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="hidden"
|
||||||
|
:value="day.value"
|
||||||
|
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
|
||||||
|
@change="toggleDay(day.value)"
|
||||||
|
/>
|
||||||
|
{{ day.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly options -->
|
||||||
|
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
|
||||||
|
{{ $t('tasks.planning.dayOfMonth') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
|
||||||
|
{{ $t('tasks.planning.weekOfMonth') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="form.monthlyMode === 'dayOfMonth'"
|
||||||
|
v-model="form.recurrenceDayOfMonth"
|
||||||
|
:label="$t('tasks.planning.dayOfMonthLabel')"
|
||||||
|
type="number"
|
||||||
|
input-class="w-full sm:w-1/3"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
/>
|
||||||
|
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
|
||||||
|
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||||
|
<option :value="1">1er</option>
|
||||||
|
<option :value="2">2ème</option>
|
||||||
|
<option :value="3">3ème</option>
|
||||||
|
<option :value="4">4ème</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
|
||||||
|
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||||
|
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End of recurrence -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.recurrenceEnd" value="never" type="radio" />
|
||||||
|
{{ $t('tasks.planning.neverEnds') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
|
||||||
|
{{ $t('tasks.planning.afterOccurrences') }}
|
||||||
|
</label>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="form.recurrenceEnd === 'occurrences'"
|
||||||
|
v-model="form.recurrenceMaxOccurrences"
|
||||||
|
type="number"
|
||||||
|
input-class="w-20"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.recurrenceEnd" value="date" type="radio" />
|
||||||
|
{{ $t('tasks.planning.onDate') }}
|
||||||
|
</label>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="form.recurrenceEnd === 'date'"
|
||||||
|
v-model="form.recurrenceEndDate"
|
||||||
|
type="date"
|
||||||
|
input-class="w-44"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div
|
<div
|
||||||
@@ -284,6 +494,7 @@ import type { TaskTag } from '~/services/dto/task-tag'
|
|||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||||
|
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
|
|
||||||
@@ -318,6 +529,7 @@ function close() {
|
|||||||
const isEditing = computed(() => !!props.task)
|
const isEditing = computed(() => !!props.task)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const confirmDeleteOpen = ref(false)
|
const confirmDeleteOpen = ref(false)
|
||||||
|
const activeTab = ref<'details' | 'planning'>('details')
|
||||||
|
|
||||||
const giteaUrl = ref('')
|
const giteaUrl = ref('')
|
||||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||||
@@ -341,6 +553,21 @@ const form = reactive({
|
|||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
clientTicketId: null as number | null,
|
clientTicketId: null as number | null,
|
||||||
projectId: null as number | null,
|
projectId: null as number | null,
|
||||||
|
scheduledStart: '',
|
||||||
|
scheduledEnd: '',
|
||||||
|
deadline: '',
|
||||||
|
syncToCalendar: false,
|
||||||
|
isRecurring: false,
|
||||||
|
recurrenceType: 'daily' as string,
|
||||||
|
recurrenceInterval: '1',
|
||||||
|
recurrenceDaysOfWeek: [] as string[],
|
||||||
|
recurrenceDayOfMonth: '',
|
||||||
|
monthlyMode: 'dayOfMonth' as string,
|
||||||
|
recurrenceWeekOfMonth: 1,
|
||||||
|
recurrenceWeekDay: 'monday' as string,
|
||||||
|
recurrenceEnd: 'never' as string,
|
||||||
|
recurrenceMaxOccurrences: '',
|
||||||
|
recurrenceEndDate: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
@@ -365,7 +592,7 @@ const userOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
let filtered = props.groups
|
let filtered = props.groups.filter(g => !g.archived)
|
||||||
if (showProjectSelect.value && form.projectId) {
|
if (showProjectSelect.value && form.projectId) {
|
||||||
filtered = filtered.filter(g => g.project?.id === form.projectId)
|
filtered = filtered.filter(g => g.project?.id === form.projectId)
|
||||||
}
|
}
|
||||||
@@ -402,6 +629,22 @@ function toggleTag(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weekDays = computed(() => [
|
||||||
|
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||||
|
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||||
|
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
|
||||||
|
{ value: 'thursday', label: t('tasks.planning.days.thu') },
|
||||||
|
{ value: 'friday', label: t('tasks.planning.days.fri') },
|
||||||
|
{ value: 'saturday', label: t('tasks.planning.days.sat') },
|
||||||
|
{ value: 'sunday', label: t('tasks.planning.days.sun') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleDay(day: string) {
|
||||||
|
const idx = form.recurrenceDaysOfWeek.indexOf(day)
|
||||||
|
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
|
||||||
|
else form.recurrenceDaysOfWeek.push(day)
|
||||||
|
}
|
||||||
|
|
||||||
function populateForm(task: Task | null) {
|
function populateForm(task: Task | null) {
|
||||||
if (task) {
|
if (task) {
|
||||||
form.title = task.title ?? ''
|
form.title = task.title ?? ''
|
||||||
@@ -413,6 +656,42 @@ function populateForm(task: Task | null) {
|
|||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
form.clientTicketId = task.clientTicket?.id ?? null
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
|
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||||
|
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||||
|
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||||
|
form.syncToCalendar = task.syncToCalendar ?? false
|
||||||
|
|
||||||
|
if (task.recurrence) {
|
||||||
|
form.isRecurring = true
|
||||||
|
form.recurrenceType = task.recurrence.type
|
||||||
|
form.recurrenceInterval = String(task.recurrence.interval)
|
||||||
|
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
|
||||||
|
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
|
||||||
|
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
|
||||||
|
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
|
||||||
|
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
|
||||||
|
if (task.recurrence.maxOccurrences) {
|
||||||
|
form.recurrenceEnd = 'occurrences'
|
||||||
|
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
|
||||||
|
} else if (task.recurrence.endDate) {
|
||||||
|
form.recurrenceEnd = 'date'
|
||||||
|
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
|
||||||
|
} else {
|
||||||
|
form.recurrenceEnd = 'never'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.isRecurring = false
|
||||||
|
form.recurrenceType = 'daily'
|
||||||
|
form.recurrenceInterval = '1'
|
||||||
|
form.recurrenceDaysOfWeek = []
|
||||||
|
form.recurrenceDayOfMonth = ''
|
||||||
|
form.monthlyMode = 'dayOfMonth'
|
||||||
|
form.recurrenceWeekOfMonth = 1
|
||||||
|
form.recurrenceWeekDay = 'monday'
|
||||||
|
form.recurrenceEnd = 'never'
|
||||||
|
form.recurrenceMaxOccurrences = ''
|
||||||
|
form.recurrenceEndDate = ''
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
form.title = ''
|
form.title = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
@@ -424,6 +703,21 @@ function populateForm(task: Task | null) {
|
|||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
form.clientTicketId = null
|
form.clientTicketId = null
|
||||||
form.projectId = null
|
form.projectId = null
|
||||||
|
form.scheduledStart = ''
|
||||||
|
form.scheduledEnd = ''
|
||||||
|
form.deadline = ''
|
||||||
|
form.syncToCalendar = false
|
||||||
|
form.isRecurring = false
|
||||||
|
form.recurrenceType = 'daily'
|
||||||
|
form.recurrenceInterval = '1'
|
||||||
|
form.recurrenceDaysOfWeek = []
|
||||||
|
form.recurrenceDayOfMonth = ''
|
||||||
|
form.monthlyMode = 'dayOfMonth'
|
||||||
|
form.recurrenceWeekOfMonth = 1
|
||||||
|
form.recurrenceWeekDay = 'monday'
|
||||||
|
form.recurrenceEnd = 'never'
|
||||||
|
form.recurrenceMaxOccurrences = ''
|
||||||
|
form.recurrenceEndDate = ''
|
||||||
}
|
}
|
||||||
touched.title = false
|
touched.title = false
|
||||||
touched.project = false
|
touched.project = false
|
||||||
@@ -431,6 +725,7 @@ function populateForm(task: Task | null) {
|
|||||||
|
|
||||||
watch(() => props.modelValue, async (open) => {
|
watch(() => props.modelValue, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
activeTab.value = 'details'
|
||||||
confirmDeleteDocOpen.value = false
|
confirmDeleteDocOpen.value = false
|
||||||
documentToDelete.value = null
|
documentToDelete.value = null
|
||||||
populateForm(props.task)
|
populateForm(props.task)
|
||||||
@@ -464,6 +759,7 @@ watch(() => props.task, (task) => {
|
|||||||
const { create, update, remove } = useTaskService()
|
const { create, update, remove } = useTaskService()
|
||||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||||
const clientTicketService = useClientTicketService()
|
const clientTicketService = useClientTicketService()
|
||||||
|
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const clientTickets = ref<ClientTicket[]>([])
|
const clientTickets = ref<ClientTicket[]>([])
|
||||||
@@ -619,12 +915,42 @@ async function handleSubmit() {
|
|||||||
project: `/api/projects/${resolvedProjectId.value}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||||
|
scheduledStart: form.scheduledStart || null,
|
||||||
|
scheduledEnd: form.scheduledEnd || null,
|
||||||
|
deadline: form.deadline || null,
|
||||||
|
syncToCalendar: form.syncToCalendar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let savedTask: Task
|
||||||
if (isEditing.value && props.task) {
|
if (isEditing.value && props.task) {
|
||||||
await update(props.task.id, payload)
|
savedTask = await update(props.task.id, payload)
|
||||||
} else {
|
} else {
|
||||||
await create(payload)
|
savedTask = await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recurrence
|
||||||
|
if (form.isRecurring) {
|
||||||
|
const recPayload = {
|
||||||
|
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
|
||||||
|
interval: parseInt(form.recurrenceInterval) || 1,
|
||||||
|
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
|
||||||
|
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
|
||||||
|
? parseInt(form.recurrenceDayOfMonth) || null : null,
|
||||||
|
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
|
||||||
|
? form.recurrenceWeekOfMonth : null,
|
||||||
|
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
|
||||||
|
maxOccurrences: form.recurrenceEnd === 'occurrences'
|
||||||
|
? parseInt(form.recurrenceMaxOccurrences) || null : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedTask.recurrence) {
|
||||||
|
await updateRecurrence(savedTask.recurrence.id, recPayload)
|
||||||
|
} else {
|
||||||
|
const recurrence = await createRecurrence(recPayload)
|
||||||
|
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
|
||||||
|
}
|
||||||
|
} else if (isEditing.value && props.task?.recurrence) {
|
||||||
|
await removeRecurrence(props.task.recurrence.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
|||||||
@@ -29,15 +29,21 @@
|
|||||||
|
|
||||||
<!-- Bottom: tags left, duration right -->
|
<!-- Bottom: tags left, duration right -->
|
||||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||||
<div v-if="entry.tags.length" class="flex items-center gap-1 overflow-hidden 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
|
<span
|
||||||
v-for="tag in entry.tags"
|
v-for="tag in visibleTags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white"
|
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 }"
|
:style="{ backgroundColor: tag.color }"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +117,17 @@ const sizeLevel = computed(() => {
|
|||||||
return 0
|
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 hasProject = computed(() => !!props.entry.project)
|
||||||
|
|
||||||
const blockStyle = computed(() => {
|
const blockStyle = computed(() => {
|
||||||
|
|||||||
@@ -105,12 +105,22 @@
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
type="submit"
|
<button
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
v-if="isEditing"
|
||||||
>
|
type="button"
|
||||||
Enregistrer
|
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||||
</button>
|
@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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
@@ -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() {
|
async function onDelete() {
|
||||||
if (!props.entry) return
|
if (!props.entry) return
|
||||||
const { remove } = useTimeEntryService()
|
const { remove } = useTimeEntryService()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="entry in sortedEntries"
|
v-for="entry in sortedEntries"
|
||||||
:key="entry.id"
|
: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)"
|
@click="emit('editEntry', entry)"
|
||||||
>
|
>
|
||||||
<!-- Color bar -->
|
<!-- Color bar -->
|
||||||
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
<!-- Main info -->
|
<!-- Main info -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
{{ entry.title || $t('common.untitled') }}
|
||||||
{{ entry.title || $t('common.untitled') }}
|
</div>
|
||||||
</span>
|
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="tag in entry.tags"
|
v-for="tag in entry.tags"
|
||||||
:key="tag.id"
|
: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 }"
|
:style="{ backgroundColor: tag.color }"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
|
|||||||
@@ -201,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
|
|||||||
// Scroll to current hour on mount
|
// Scroll to current hour on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!calendarEl.value) return
|
if (!gridBodyEl.value) return
|
||||||
const scrollParent = getScrollParent()
|
|
||||||
if (!scrollParent) return
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
const calendarTop = calendarEl.value.offsetTop
|
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||||
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||||
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
|
||||||
|
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
@click="ui.toggleDarkMode()"
|
||||||
|
>
|
||||||
|
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
|
||||||
|
</button>
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div class="group relative flex gap-2 sm:gap-4">
|
<div class="group relative flex gap-2 sm:gap-4">
|
||||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="date-filter">
|
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
|
||||||
<VueDatePicker
|
<VueDatePicker
|
||||||
ref="datepicker"
|
ref="datepicker"
|
||||||
v-model="internalValue"
|
v-model="internalValue"
|
||||||
@@ -14,29 +14,11 @@
|
|||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="flex items-center gap-1">
|
<button
|
||||||
<div class="relative cursor-pointer">
|
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
|
||||||
<input
|
>
|
||||||
:value="displayValue"
|
<Icon name="mdi:calendar-blank" size="20" />
|
||||||
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"
|
</button>
|
||||||
: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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #action-buttons>
|
<template #action-buttons>
|
||||||
|
|||||||
@@ -106,7 +106,47 @@
|
|||||||
"deleteConfirmTitle": "Supprimer le ticket",
|
"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",
|
"addTask": "Ajouter un ticket",
|
||||||
"editTask": "Modifier un ticket"
|
"editTask": "Modifier un ticket",
|
||||||
|
"detailsTab": "Détails",
|
||||||
|
"planningTab": "Planification",
|
||||||
|
"planning": {
|
||||||
|
"dates": "Dates",
|
||||||
|
"scheduledStart": "Début planifié",
|
||||||
|
"scheduledEnd": "Fin planifiée",
|
||||||
|
"deadline": "Deadline",
|
||||||
|
"calendar": "Calendrier",
|
||||||
|
"syncToCalendar": "Envoyer au calendrier Zimbra",
|
||||||
|
"syncOk": "Synchronisé",
|
||||||
|
"recurrence": "Récurrence",
|
||||||
|
"isRecurring": "Tâche récurrente",
|
||||||
|
"type": "Type",
|
||||||
|
"daily": "Quotidien",
|
||||||
|
"weekly": "Hebdomadaire",
|
||||||
|
"monthly": "Mensuel",
|
||||||
|
"yearly": "Annuel",
|
||||||
|
"interval": "Intervalle",
|
||||||
|
"daysOfWeek": "Jours de la semaine",
|
||||||
|
"days": {
|
||||||
|
"mon": "Lu",
|
||||||
|
"tue": "Ma",
|
||||||
|
"wed": "Me",
|
||||||
|
"thu": "Je",
|
||||||
|
"fri": "Ve",
|
||||||
|
"sat": "Sa",
|
||||||
|
"sun": "Di"
|
||||||
|
},
|
||||||
|
"dayOfMonth": "Jour du mois",
|
||||||
|
"dayOfMonthLabel": "Jour (1-31)",
|
||||||
|
"weekOfMonth": "Semaine du mois",
|
||||||
|
"weekOfMonthLabel": "Semaine",
|
||||||
|
"dayLabel": "Jour",
|
||||||
|
"endRecurrence": "Fin de la récurrence",
|
||||||
|
"neverEnds": "Jamais",
|
||||||
|
"afterOccurrences": "Après X occurrences",
|
||||||
|
"occurrences": "Occurrences",
|
||||||
|
"onDate": "À une date",
|
||||||
|
"endDate": "Date de fin"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
@@ -146,7 +186,11 @@
|
|||||||
"allAssignees": "Tous",
|
"allAssignees": "Tous",
|
||||||
"noTasks": "Aucune tâche",
|
"noTasks": "Aucune tâche",
|
||||||
"backlog": "Backlog",
|
"backlog": "Backlog",
|
||||||
"createTask": "Créer une tâche"
|
"createTask": "Créer une tâche",
|
||||||
|
"sortBy": "Trier par",
|
||||||
|
"sortDefault": "Par défaut",
|
||||||
|
"sortDeadline": "Échéance",
|
||||||
|
"sortScheduledStart": "Date planifiée"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -358,5 +402,35 @@
|
|||||||
"noResults": "Aucun résultat",
|
"noResults": "Aucun résultat",
|
||||||
"empty": "Aucun document lié"
|
"empty": "Aucun document lié"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zimbra": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Calendrier Zimbra",
|
||||||
|
"serverUrl": "URL du serveur CalDAV",
|
||||||
|
"serverUrlPlaceholder": "https://mail.ovh.com",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"usernamePlaceholder": "user{'@'}domain.com",
|
||||||
|
"calendarPath": "Chemin du calendrier",
|
||||||
|
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"passwordConfigured": "Mot de passe configuré",
|
||||||
|
"enabled": "Activer la synchronisation CalDAV",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saved": "Configuration Zimbra enregistrée",
|
||||||
|
"testConnection": "Tester la connexion",
|
||||||
|
"testSuccess": "Connexion réussie",
|
||||||
|
"testFailed": "Connexion échouée"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskRecurrence": {
|
||||||
|
"created": "Récurrence créée",
|
||||||
|
"updated": "Récurrence mise à jour",
|
||||||
|
"deleted": "Récurrence supprimée"
|
||||||
|
},
|
||||||
|
"recurrence": {
|
||||||
|
"daily": "Quotidien",
|
||||||
|
"weekly": "Hebdomadaire",
|
||||||
|
"monthly": "Mensuel",
|
||||||
|
"yearly": "Annuel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,14 @@
|
|||||||
sub
|
sub
|
||||||
@click="ui.closeMobileSidebar()"
|
@click="ui.closeMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||||
|
icon="mdi:ticket-outline"
|
||||||
|
label="Tickets client"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
sub
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/time-tracking"
|
to="/time-tracking"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export default defineNuxtConfig({
|
|||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: {enabled: false},
|
devtools: {enabled: false},
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
css: ['~/assets/css/dark.css'],
|
||||||
app: {
|
app: {
|
||||||
baseURL: process.env.NODE_ENV === 'production'
|
baseURL: process.env.NODE_ENV === 'production'
|
||||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
<AdminUserTab v-if="activeTab === 'users'" />
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
|
||||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||||
|
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -45,9 +45,9 @@ const tabs = [
|
|||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
{ key: 'users', label: 'Utilisateurs' },
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
{ key: 'client-tickets', label: 'Tickets client' },
|
|
||||||
{ key: 'gitea', label: 'Gitea' },
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
{ key: 'bookstack', label: 'BookStack' },
|
{ key: 'bookstack', label: 'BookStack' },
|
||||||
|
{ key: 'zimbra', label: 'Zimbra' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TabKey = typeof tabs[number]['key']
|
type TabKey = typeof tabs[number]['key']
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
|
|||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
useHead({ title: t('myTasks.title') })
|
useHead({ title: t('myTasks.title') })
|
||||||
@@ -48,9 +50,16 @@ const selectedPriorityId = ref<number | null>(null)
|
|||||||
const selectedEffortId = ref<number | null>(null)
|
const selectedEffortId = ref<number | null>(null)
|
||||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||||
|
const sortBy = ref<SortOption>('default')
|
||||||
|
|
||||||
// View toggle
|
// View toggle
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
const taskModalOpen = ref(false)
|
const taskModalOpen = ref(false)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
@@ -152,6 +161,11 @@ async function loadTasks() {
|
|||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||||
}
|
}
|
||||||
|
if (sortBy.value === 'deadline') {
|
||||||
|
params['order[deadline]'] = 'asc'
|
||||||
|
} else if (sortBy.value === 'scheduledStart') {
|
||||||
|
params['order[scheduledStart]'] = 'asc'
|
||||||
|
}
|
||||||
tasks.value = await taskService.getFiltered(params)
|
tasks.value = await taskService.getFiltered(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +178,9 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch filters to reload tasks
|
// Watch filters and sort to reload tasks
|
||||||
watch(
|
watch(
|
||||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||||
() => { loadTasks() },
|
() => { loadTasks() },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,19 +231,89 @@ async function onDropBacklog(event: DragEvent) {
|
|||||||
function openTaskCreate() {
|
function openTaskCreate() {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
taskModalOpen.value = true
|
taskModalOpen.value = true
|
||||||
|
router.replace({ query: {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTaskEdit(task: Task) {
|
function openTaskEdit(task: Task) {
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
taskModalOpen.value = true
|
taskModalOpen.value = true
|
||||||
|
if (task.project?.code && task.number) {
|
||||||
|
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(taskModalOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
router.replace({ query: {} })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function onSaved() {
|
async function onSaved() {
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function toggleTaskSelect(taskId: number) {
|
||||||
loadAll()
|
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(async () => {
|
||||||
|
await loadAll()
|
||||||
|
const taskParam = route.query.task as string | undefined
|
||||||
|
if (taskParam) {
|
||||||
|
const dashIndex = taskParam.lastIndexOf('-')
|
||||||
|
if (dashIndex > 0) {
|
||||||
|
const code = taskParam.slice(0, dashIndex)
|
||||||
|
const num = Number(taskParam.slice(dashIndex + 1))
|
||||||
|
if (num) {
|
||||||
|
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
|
||||||
|
if (task) {
|
||||||
|
openTaskEdit(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -247,24 +331,16 @@ onMounted(() => {
|
|||||||
<Icon name="mdi:plus" size="18" />
|
<Icon name="mdi:plus" size="18" />
|
||||||
{{ $t('myTasks.createTask') }}
|
{{ $t('myTasks.createTask') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex gap-1">
|
<button
|
||||||
<button
|
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
:class="viewMode === 'list'
|
||||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
? 'border-primary-500 bg-primary-500 text-white'
|
||||||
:title="$t('myTasks.viewKanban')"
|
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||||
@click="viewMode = 'kanban'"
|
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||||
>
|
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||||
<Icon name="mdi:view-column-outline" size="18" />
|
>
|
||||||
</button>
|
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||||
<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'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:view-list-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,6 +399,17 @@ onMounted(() => {
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||||
|
<select
|
||||||
|
v-model="sortBy"
|
||||||
|
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||||
|
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||||
|
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,6 +438,7 @@ onMounted(() => {
|
|||||||
v-for="task in tasksByStatus(status.id)"
|
v-for="task in tasksByStatus(status.id)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
show-project-color
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
@@ -379,6 +467,7 @@ onMounted(() => {
|
|||||||
v-for="task in backlogTasks"
|
v-for="task in backlogTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
show-project-color
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,57 +481,31 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-if="viewMode === 'list'" class="mt-6">
|
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||||
<div
|
<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"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
: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)"
|
@click="openTaskEdit(task)"
|
||||||
>
|
@toggle-select="toggleTaskSelect"
|
||||||
<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>
|
|
||||||
<p
|
<p
|
||||||
v-if="tasks.length === 0 && !isLoading"
|
v-if="tasks.length === 0 && !isLoading"
|
||||||
class="py-8 text-center text-sm text-neutral-400"
|
class="py-8 text-center text-sm text-neutral-400"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<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>
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
@@ -45,12 +46,21 @@
|
|||||||
@cancel="selectedFile = null"
|
@cancel="selectedFile = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
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 { upload, remove } = useAvatarService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
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>
|
||||||
@@ -11,6 +11,16 @@
|
|||||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||||
<span class="sm:hidden">+ Ticket</span>
|
<span class="sm:hidden">+ Ticket</span>
|
||||||
</button>
|
</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
|
<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"
|
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"
|
title="Paramètres du projet"
|
||||||
@@ -58,11 +68,29 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kanban -->
|
<!-- Kanban -->
|
||||||
<div class="mt-6 flex h-[calc(100vh-200px)] gap-3 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
|
<div
|
||||||
v-for="status in statuses"
|
v-for="status in statuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
@@ -100,6 +128,7 @@
|
|||||||
|
|
||||||
<!-- Backlog -->
|
<!-- Backlog -->
|
||||||
<div
|
<div
|
||||||
|
v-if="viewMode === 'kanban'"
|
||||||
class="mt-8 rounded-lg p-4 transition-colors"
|
class="mt-8 rounded-lg p-4 transition-colors"
|
||||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@@ -118,6 +147,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<TaskModal
|
||||||
v-model="taskDrawerOpen"
|
v-model="taskDrawerOpen"
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
@@ -162,6 +224,7 @@ import { useTaskGroupService } from '~/services/task-groups'
|
|||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const projectId = computed(() => Number(route.params.id))
|
const projectId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
useHead({ title: 'Projet' })
|
useHead({ title: 'Projet' })
|
||||||
@@ -191,6 +254,10 @@ const selectedGroupId = ref<number | null>(null)
|
|||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedAssigneeId = ref<number | null>(null)
|
const selectedAssigneeId = ref<number | null>(null)
|
||||||
const selectedStatusId = 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 dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const taskDrawerOpen = ref(false)
|
const taskDrawerOpen = ref(false)
|
||||||
@@ -213,6 +280,14 @@ const statusFilterOptions = computed(() =>
|
|||||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
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(() => {
|
const filteredTasks = computed(() => {
|
||||||
let result = tasks.value.filter(t => !t.archived)
|
let result = tasks.value.filter(t => !t.archived)
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
@@ -227,6 +302,12 @@ const filteredTasks = computed(() => {
|
|||||||
if (selectedStatusId.value) {
|
if (selectedStatusId.value) {
|
||||||
result = result.filter(t => t.status?.id === 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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -269,13 +350,23 @@ async function loadData() {
|
|||||||
function openTaskCreate() {
|
function openTaskCreate() {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
taskDrawerOpen.value = true
|
taskDrawerOpen.value = true
|
||||||
|
router.replace({ query: {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTaskEdit(task: Task) {
|
function openTaskEdit(task: Task) {
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
taskDrawerOpen.value = true
|
taskDrawerOpen.value = true
|
||||||
|
if (project.value?.code && task.number) {
|
||||||
|
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(taskDrawerOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
router.replace({ query: {} })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function onDragEnter(id: number) {
|
function onDragEnter(id: number) {
|
||||||
dragCounter.value++
|
dragCounter.value++
|
||||||
dragOverStatusId.value = id
|
dragOverStatusId.value = id
|
||||||
@@ -311,6 +402,52 @@ async function onDropBacklog(event: DragEvent) {
|
|||||||
await taskService.update(taskId, { status: null })
|
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() {
|
async function onSaved() {
|
||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
@@ -319,7 +456,20 @@ async function onProjectSaved() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadData()
|
await loadData()
|
||||||
|
const taskParam = route.query.task as string | undefined
|
||||||
|
if (taskParam && project.value) {
|
||||||
|
const prefix = `${project.value.code}-`
|
||||||
|
if (taskParam.startsWith(prefix)) {
|
||||||
|
const num = Number(taskParam.slice(prefix.length))
|
||||||
|
if (num) {
|
||||||
|
const task = tasks.value.find(t => t.number === num)
|
||||||
|
if (task) {
|
||||||
|
openTaskEdit(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,34 +13,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
|
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||||
{{ currentMonthLabel }}
|
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
|
||||||
<button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
|
||||||
<Icon name="mdi:chevron-left" size="20" />
|
<Icon name="mdi:chevron-left" size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||||
<button
|
{{ currentMonthLabel }}
|
||||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
</h2>
|
||||||
:key="mode"
|
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
|
||||||
<Icon name="mdi:chevron-right" size="20" />
|
<Icon name="mdi:chevron-right" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedUserId"
|
v-model="selectedUserId"
|
||||||
@@ -76,8 +75,6 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
22
frontend/services/dto/task-recurrence.ts
Normal file
22
frontend/services/dto/task-recurrence.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type TaskRecurrence = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
interval: number
|
||||||
|
daysOfWeek: string[] | null
|
||||||
|
dayOfMonth: number | null
|
||||||
|
weekOfMonth: number | null
|
||||||
|
endDate: string | null
|
||||||
|
maxOccurrences: number | null
|
||||||
|
occurrenceCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskRecurrenceWrite = {
|
||||||
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
interval: number
|
||||||
|
daysOfWeek?: string[] | null
|
||||||
|
dayOfMonth?: number | null
|
||||||
|
weekOfMonth?: number | null
|
||||||
|
endDate?: string | null
|
||||||
|
maxOccurrences?: number | null
|
||||||
|
}
|
||||||
@@ -29,6 +29,23 @@ export type Task = {
|
|||||||
status: string
|
status: string
|
||||||
title: string
|
title: string
|
||||||
} | null
|
} | null
|
||||||
|
scheduledStart: string | null
|
||||||
|
scheduledEnd: string | null
|
||||||
|
deadline: string | null
|
||||||
|
syncToCalendar: boolean
|
||||||
|
calendarSyncError: string | null
|
||||||
|
recurrence: {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
interval: number
|
||||||
|
daysOfWeek: string[] | null
|
||||||
|
dayOfMonth: number | null
|
||||||
|
weekOfMonth: number | null
|
||||||
|
endDate: string | null
|
||||||
|
maxOccurrences: number | null
|
||||||
|
occurrenceCount: number
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskWrite = {
|
export type TaskWrite = {
|
||||||
@@ -43,4 +60,9 @@ export type TaskWrite = {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
clientTicket?: string | null
|
clientTicket?: string | null
|
||||||
|
scheduledStart?: string | null
|
||||||
|
scheduledEnd?: string | null
|
||||||
|
deadline?: string | null
|
||||||
|
syncToCalendar?: boolean
|
||||||
|
recurrence?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/services/dto/zimbra.ts
Normal file
19
frontend/services/dto/zimbra.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type ZimbraSettings = {
|
||||||
|
serverUrl: string | null
|
||||||
|
username: string | null
|
||||||
|
calendarPath: string | null
|
||||||
|
enabled: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZimbraSettingsWrite = {
|
||||||
|
serverUrl: string | null
|
||||||
|
username: string | null
|
||||||
|
calendarPath: string | null
|
||||||
|
password?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZimbraTestResult = {
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
25
frontend/services/task-recurrences.ts
Normal file
25
frontend/services/task-recurrences.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TaskRecurrence, TaskRecurrenceWrite } from './dto/task-recurrence'
|
||||||
|
|
||||||
|
export function useTaskRecurrenceService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
|
||||||
|
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskRecurrence.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
|
||||||
|
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskRecurrence.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_recurrences/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskRecurrence.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { create, update, remove }
|
||||||
|
}
|
||||||
21
frontend/services/zimbra.ts
Normal file
21
frontend/services/zimbra.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ZimbraSettings, ZimbraSettingsWrite, ZimbraTestResult } from './dto/zimbra'
|
||||||
|
|
||||||
|
export function useZimbraService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getSettings(): Promise<ZimbraSettings> {
|
||||||
|
return api.get<ZimbraSettings>('/settings/zimbra')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
|
||||||
|
return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'zimbra.settings.saved',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(): Promise<ZimbraTestResult> {
|
||||||
|
return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getSettings, saveSettings, testConnection }
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
const darkMode = ref(false)
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
||||||
if (saved !== null) {
|
if (saved !== null) {
|
||||||
sidebarCollapsed.value = saved === 'true'
|
sidebarCollapsed.value = saved === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDark = localStorage.getItem('ui-dark-mode')
|
||||||
|
if (savedDark !== null) {
|
||||||
|
darkMode.value = savedDark === 'true'
|
||||||
|
}
|
||||||
|
applyDarkClass(darkMode.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(sidebarCollapsed, (val) => {
|
watch(sidebarCollapsed, (val) => {
|
||||||
@@ -15,6 +22,25 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(darkMode, (val) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('ui-dark-mode', String(val))
|
||||||
|
applyDarkClass(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyDarkClass(dark: boolean) {
|
||||||
|
if (dark) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDarkMode() {
|
||||||
|
darkMode.value = !darkMode.value
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
}
|
}
|
||||||
@@ -27,5 +53,5 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sidebarCollapsed, sidebarOpen, toggleSidebar, openMobileSidebar, closeMobileSidebar }
|
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {Config} from 'tailwindcss'
|
import type {Config} from 'tailwindcss'
|
||||||
|
|
||||||
export default <Partial<Config>>{
|
export default <Partial<Config>>{
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
53
migrations/Version20260319090835.php
Normal file
53
migrations/Version20260319090835.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260319090835 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE task_recurrence (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(255) NOT NULL, interval INT NOT NULL, days_of_week JSON DEFAULT NULL, day_of_month INT DEFAULT NULL, week_of_month INT DEFAULT NULL, end_date DATE DEFAULT NULL, max_occurrences INT DEFAULT NULL, occurrence_count INT NOT NULL, version INT DEFAULT 1 NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE TABLE zimbra_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, server_url VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, calendar_path VARCHAR(255) DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('ALTER TABLE task ADD scheduled_start TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD scheduled_end TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD deadline TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD sync_to_calendar BOOLEAN DEFAULT false NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD calendar_event_uid VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD calendar_todo_uid VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD calendar_sync_error TEXT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD recurrence_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB252C414CE8 FOREIGN KEY (recurrence_id) REFERENCES task_recurrence (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB252C414CE8 ON task (recurrence_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP TABLE task_recurrence');
|
||||||
|
$this->addSql('DROP TABLE zimbra_configuration');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB252C414CE8');
|
||||||
|
$this->addSql('DROP INDEX IDX_527EDB252C414CE8');
|
||||||
|
$this->addSql('ALTER TABLE task DROP scheduled_start');
|
||||||
|
$this->addSql('ALTER TABLE task DROP scheduled_end');
|
||||||
|
$this->addSql('ALTER TABLE task DROP deadline');
|
||||||
|
$this->addSql('ALTER TABLE task DROP sync_to_calendar');
|
||||||
|
$this->addSql('ALTER TABLE task DROP calendar_event_uid');
|
||||||
|
$this->addSql('ALTER TABLE task DROP calendar_todo_uid');
|
||||||
|
$this->addSql('ALTER TABLE task DROP calendar_sync_error');
|
||||||
|
$this->addSql('ALTER TABLE task DROP recurrence_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/ApiResource/ZimbraSettings.php
Normal file
51
src/ApiResource/ZimbraSettings.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use App\State\ZimbraSettingsProcessor;
|
||||||
|
use App\State\ZimbraSettingsProvider;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/settings/zimbra',
|
||||||
|
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
||||||
|
provider: ZimbraSettingsProvider::class,
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
),
|
||||||
|
new Put(
|
||||||
|
uriTemplate: '/settings/zimbra',
|
||||||
|
denormalizationContext: ['groups' => ['zimbra_settings:write']],
|
||||||
|
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
||||||
|
provider: ZimbraSettingsProvider::class,
|
||||||
|
processor: ZimbraSettingsProcessor::class,
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class ZimbraSettings
|
||||||
|
{
|
||||||
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||||
|
public ?string $serverUrl = null;
|
||||||
|
|
||||||
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||||
|
public ?string $username = null;
|
||||||
|
|
||||||
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||||
|
public ?string $calendarPath = null;
|
||||||
|
|
||||||
|
#[Groups(['zimbra_settings:write'])]
|
||||||
|
public ?string $password = null;
|
||||||
|
|
||||||
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||||
|
public bool $enabled = false;
|
||||||
|
|
||||||
|
#[Groups(['zimbra_settings:read'])]
|
||||||
|
public bool $hasPassword = false;
|
||||||
|
}
|
||||||
28
src/ApiResource/ZimbraTestConnection.php
Normal file
28
src/ApiResource/ZimbraTestConnection.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\State\ZimbraTestConnectionProvider;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/settings/zimbra/test',
|
||||||
|
input: false,
|
||||||
|
normalizationContext: ['groups' => ['zimbra_test:read']],
|
||||||
|
provider: ZimbraTestConnectionProvider::class,
|
||||||
|
processor: ZimbraTestConnectionProvider::class,
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class ZimbraTestConnection
|
||||||
|
{
|
||||||
|
#[Groups(['zimbra_test:read'])]
|
||||||
|
public bool $success = false;
|
||||||
|
}
|
||||||
@@ -11,10 +11,13 @@ use App\Entity\Task;
|
|||||||
use App\Entity\TaskEffort;
|
use App\Entity\TaskEffort;
|
||||||
use App\Entity\TaskGroup;
|
use App\Entity\TaskGroup;
|
||||||
use App\Entity\TaskPriority;
|
use App\Entity\TaskPriority;
|
||||||
|
use App\Entity\TaskRecurrence;
|
||||||
use App\Entity\TaskStatus;
|
use App\Entity\TaskStatus;
|
||||||
use App\Entity\TaskTag;
|
use App\Entity\TaskTag;
|
||||||
use App\Entity\TimeEntry;
|
use App\Entity\TimeEntry;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Entity\ZimbraConfiguration;
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
@@ -274,6 +277,9 @@ class AppFixtures extends Fixture
|
|||||||
$task2->setGroup($groupFrontend);
|
$task2->setGroup($groupFrontend);
|
||||||
$task2->setProject($projectSirh);
|
$task2->setProject($projectSirh);
|
||||||
$task2->addTag($tagAuth);
|
$task2->addTag($tagAuth);
|
||||||
|
$task2->setScheduledStart(new DateTimeImmutable('next monday 09:00'));
|
||||||
|
$task2->setScheduledEnd(new DateTimeImmutable('next monday 17:00'));
|
||||||
|
$task2->setSyncToCalendar(false);
|
||||||
$manager->persist($task2);
|
$manager->persist($task2);
|
||||||
|
|
||||||
$task3 = new Task();
|
$task3 = new Task();
|
||||||
@@ -308,6 +314,8 @@ class AppFixtures extends Fixture
|
|||||||
$task5->setAssignee($userCharlie);
|
$task5->setAssignee($userCharlie);
|
||||||
$task5->setProject($projectSirh);
|
$task5->setProject($projectSirh);
|
||||||
$task5->addTag($tagCalendar);
|
$task5->addTag($tagCalendar);
|
||||||
|
$task5->setDeadline(new DateTimeImmutable('+2 weeks'));
|
||||||
|
$task5->setSyncToCalendar(false);
|
||||||
$manager->persist($task5);
|
$manager->persist($task5);
|
||||||
|
|
||||||
$task6 = new Task();
|
$task6 = new Task();
|
||||||
@@ -414,6 +422,8 @@ class AppFixtures extends Fixture
|
|||||||
$taskErp3->setAssignee($admin);
|
$taskErp3->setAssignee($admin);
|
||||||
$taskErp3->setGroup($groupErpFacturation);
|
$taskErp3->setGroup($groupErpFacturation);
|
||||||
$taskErp3->setProject($projectErp);
|
$taskErp3->setProject($projectErp);
|
||||||
|
$taskErp3->setDeadline(new DateTimeImmutable('+1 month'));
|
||||||
|
$taskErp3->setSyncToCalendar(false);
|
||||||
$manager->persist($taskErp3);
|
$manager->persist($taskErp3);
|
||||||
|
|
||||||
$taskErp4 = new Task();
|
$taskErp4 = new Task();
|
||||||
@@ -650,6 +660,39 @@ class AppFixtures extends Fixture
|
|||||||
// Link a task to a client ticket
|
// Link a task to a client ticket
|
||||||
$task3->setClientTicket($ticket1);
|
$task3->setClientTicket($ticket1);
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Zimbra Configuration
|
||||||
|
// =============================================
|
||||||
|
$zimbraConfig = new ZimbraConfiguration();
|
||||||
|
$zimbraConfig->setServerUrl('https://mail.ovh.com');
|
||||||
|
$zimbraConfig->setUsername('lesstime@ovh.fr');
|
||||||
|
$zimbraConfig->setCalendarPath('/dav/lesstime@ovh.fr/Calendar/');
|
||||||
|
$zimbraConfig->setEnabled(false);
|
||||||
|
$manager->persist($zimbraConfig);
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Task Recurrence — exemple hebdomadaire
|
||||||
|
// =============================================
|
||||||
|
$recurrence = new TaskRecurrence();
|
||||||
|
$recurrence->setType(RecurrenceType::Weekly);
|
||||||
|
$recurrence->setInterval(1);
|
||||||
|
$recurrence->setDaysOfWeek(['monday', 'wednesday', 'friday']);
|
||||||
|
$manager->persist($recurrence);
|
||||||
|
|
||||||
|
$taskRecurring = new Task();
|
||||||
|
$taskRecurring->setNumber(7);
|
||||||
|
$taskRecurring->setTitle('Réunion de suivi hebdomadaire');
|
||||||
|
$taskRecurring->setStatus($statusTodo);
|
||||||
|
$taskRecurring->setEffort($effortS);
|
||||||
|
$taskRecurring->setPriority($priorityMedium);
|
||||||
|
$taskRecurring->setAssignee($admin);
|
||||||
|
$taskRecurring->setProject($projectSirh);
|
||||||
|
$taskRecurring->setScheduledStart(new DateTimeImmutable('next monday 10:00'));
|
||||||
|
$taskRecurring->setScheduledEnd(new DateTimeImmutable('next monday 10:30'));
|
||||||
|
$taskRecurring->setSyncToCalendar(false);
|
||||||
|
$taskRecurring->setRecurrence($recurrence);
|
||||||
|
$manager->persist($taskRecurring);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
@@ -14,26 +16,32 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
|
use App\State\TaskCalendarProcessor;
|
||||||
use App\State\TaskNumberProcessor;
|
use App\State\TaskNumberProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||||
new Get(security: "is_granted('ROLE_USER')"),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
||||||
],
|
],
|
||||||
normalizationContext: ['groups' => ['task:read']],
|
normalizationContext: ['groups' => ['task:read']],
|
||||||
denormalizationContext: ['groups' => ['task:write']],
|
denormalizationContext: ['groups' => ['task:write']],
|
||||||
order: ['id' => 'DESC'],
|
order: ['id' => 'DESC'],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
||||||
|
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
||||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||||
#[ORM\Table(name: 'task')]
|
#[ORM\Table(name: 'task')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
|
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
|
||||||
@@ -111,6 +119,37 @@ class Task
|
|||||||
#[Groups(['task:read', 'task:write'])]
|
#[Groups(['task:read', 'task:write'])]
|
||||||
private ?ClientTicket $clientTicket = null;
|
private ?ClientTicket $clientTicket = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?DateTimeImmutable $scheduledStart = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?DateTimeImmutable $scheduledEnd = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?DateTimeImmutable $deadline = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private bool $syncToCalendar = false;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $calendarEventUid = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $calendarTodoUid = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['task:read'])]
|
||||||
|
private ?string $calendarSyncError = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?TaskRecurrence $recurrence = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tags = new ArrayCollection();
|
$this->tags = new ArrayCollection();
|
||||||
@@ -281,4 +320,118 @@ class Task
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getScheduledStart(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->scheduledStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setScheduledStart(?DateTimeImmutable $scheduledStart): static
|
||||||
|
{
|
||||||
|
$this->scheduledStart = $scheduledStart;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getScheduledEnd(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->scheduledEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setScheduledEnd(?DateTimeImmutable $scheduledEnd): static
|
||||||
|
{
|
||||||
|
$this->scheduledEnd = $scheduledEnd;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeadline(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeadline(?DateTimeImmutable $deadline): static
|
||||||
|
{
|
||||||
|
$this->deadline = $deadline;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSyncToCalendar(): bool
|
||||||
|
{
|
||||||
|
return $this->syncToCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSyncToCalendar(bool $syncToCalendar): static
|
||||||
|
{
|
||||||
|
$this->syncToCalendar = $syncToCalendar;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCalendarEventUid(): ?string
|
||||||
|
{
|
||||||
|
return $this->calendarEventUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCalendarEventUid(?string $calendarEventUid): static
|
||||||
|
{
|
||||||
|
$this->calendarEventUid = $calendarEventUid;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCalendarTodoUid(): ?string
|
||||||
|
{
|
||||||
|
return $this->calendarTodoUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCalendarTodoUid(?string $calendarTodoUid): static
|
||||||
|
{
|
||||||
|
$this->calendarTodoUid = $calendarTodoUid;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCalendarSyncError(): ?string
|
||||||
|
{
|
||||||
|
return $this->calendarSyncError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCalendarSyncError(?string $calendarSyncError): static
|
||||||
|
{
|
||||||
|
$this->calendarSyncError = $calendarSyncError;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecurrence(): ?TaskRecurrence
|
||||||
|
{
|
||||||
|
return $this->recurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRecurrence(?TaskRecurrence $recurrence): static
|
||||||
|
{
|
||||||
|
$this->recurrence = $recurrence;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateScheduledDates(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if ((null === $this->scheduledStart) !== (null === $this->scheduledEnd)) {
|
||||||
|
$context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.')
|
||||||
|
->atPath('scheduledEnd')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
if (null !== $this->scheduledStart && null !== $this->scheduledEnd
|
||||||
|
&& $this->scheduledEnd <= $this->scheduledStart) {
|
||||||
|
$context->buildViolation('scheduledEnd must be after scheduledStart.')
|
||||||
|
->atPath('scheduledEnd')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
197
src/Entity/TaskRecurrence.php
Normal file
197
src/Entity/TaskRecurrence.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
|
use App\Repository\TaskRecurrenceRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||||
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_recurrence:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_recurrence:write']],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskRecurrenceRepository::class)]
|
||||||
|
class TaskRecurrence
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_recurrence:read', 'task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', enumType: RecurrenceType::class)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?RecurrenceType $type = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private int $interval = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?array $daysOfWeek = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?int $dayOfMonth = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?int $weekOfMonth = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||||
|
private ?int $maxOccurrences = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['task_recurrence:read'])]
|
||||||
|
private int $occurrenceCount = 0;
|
||||||
|
|
||||||
|
#[ORM\Version]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $version = 1;
|
||||||
|
|
||||||
|
/** @var Collection<int, Task> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
|
||||||
|
private Collection $tasks;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tasks = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): ?RecurrenceType
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setType(RecurrenceType $type): static
|
||||||
|
{
|
||||||
|
$this->type = $type;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInterval(): int
|
||||||
|
{
|
||||||
|
return $this->interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInterval(int $interval): static
|
||||||
|
{
|
||||||
|
$this->interval = $interval;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDaysOfWeek(): ?array
|
||||||
|
{
|
||||||
|
return $this->daysOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDaysOfWeek(?array $daysOfWeek): static
|
||||||
|
{
|
||||||
|
$this->daysOfWeek = $daysOfWeek;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDayOfMonth(): ?int
|
||||||
|
{
|
||||||
|
return $this->dayOfMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDayOfMonth(?int $dayOfMonth): static
|
||||||
|
{
|
||||||
|
$this->dayOfMonth = $dayOfMonth;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeekOfMonth(): ?int
|
||||||
|
{
|
||||||
|
return $this->weekOfMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWeekOfMonth(?int $weekOfMonth): static
|
||||||
|
{
|
||||||
|
$this->weekOfMonth = $weekOfMonth;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?DateTimeImmutable $endDate): static
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMaxOccurrences(): ?int
|
||||||
|
{
|
||||||
|
return $this->maxOccurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMaxOccurrences(?int $maxOccurrences): static
|
||||||
|
{
|
||||||
|
$this->maxOccurrences = $maxOccurrences;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOccurrenceCount(): int
|
||||||
|
{
|
||||||
|
return $this->occurrenceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): int
|
||||||
|
{
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Task> */
|
||||||
|
public function getTasks(): Collection
|
||||||
|
{
|
||||||
|
return $this->tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incrementOccurrenceCount(): static
|
||||||
|
{
|
||||||
|
++$this->occurrenceCount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Entity/ZimbraConfiguration.php
Normal file
104
src/Entity/ZimbraConfiguration.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ZimbraConfigurationRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ZimbraConfigurationRepository::class)]
|
||||||
|
class ZimbraConfiguration
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Assert\Url]
|
||||||
|
private ?string $serverUrl = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $username = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $encryptedPassword = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $calendarPath = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean')]
|
||||||
|
private bool $enabled = false;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServerUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setServerUrl(?string $serverUrl): static
|
||||||
|
{
|
||||||
|
$this->serverUrl = $serverUrl;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsername(): ?string
|
||||||
|
{
|
||||||
|
return $this->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUsername(?string $username): static
|
||||||
|
{
|
||||||
|
$this->username = $username;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEncryptedPassword(): ?string
|
||||||
|
{
|
||||||
|
return $this->encryptedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEncryptedPassword(?string $encryptedPassword): static
|
||||||
|
{
|
||||||
|
$this->encryptedPassword = $encryptedPassword;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCalendarPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->calendarPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCalendarPath(?string $calendarPath): static
|
||||||
|
{
|
||||||
|
$this->calendarPath = $calendarPath;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $enabled): static
|
||||||
|
{
|
||||||
|
$this->enabled = $enabled;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPassword(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->encryptedPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Enum/RecurrenceType.php
Normal file
13
src/Enum/RecurrenceType.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum RecurrenceType: string
|
||||||
|
{
|
||||||
|
case Daily = 'daily';
|
||||||
|
case Weekly = 'weekly';
|
||||||
|
case Monthly = 'monthly';
|
||||||
|
case Yearly = 'yearly';
|
||||||
|
}
|
||||||
90
src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php
Normal file
90
src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
|
use App\Entity\TaskRecurrence;
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
|
use App\Repository\TaskRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array (e.g. ["monday","wednesday"]). For monthly, provide dayOfMonth OR weekOfMonth.')]
|
||||||
|
class CreateTaskRecurrenceTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly TaskRepository $taskRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $taskId,
|
||||||
|
string $type,
|
||||||
|
int $interval = 1,
|
||||||
|
?array $daysOfWeek = null,
|
||||||
|
?int $dayOfMonth = null,
|
||||||
|
?int $weekOfMonth = null,
|
||||||
|
?string $endDate = null,
|
||||||
|
?int $maxOccurrences = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $this->taskRepository->find($taskId);
|
||||||
|
if (null === $task) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurrenceType = RecurrenceType::from($type);
|
||||||
|
|
||||||
|
$recurrence = new TaskRecurrence();
|
||||||
|
$recurrence->setType($recurrenceType);
|
||||||
|
$recurrence->setInterval($interval);
|
||||||
|
|
||||||
|
if (null !== $daysOfWeek) {
|
||||||
|
$recurrence->setDaysOfWeek($daysOfWeek);
|
||||||
|
}
|
||||||
|
if (null !== $dayOfMonth) {
|
||||||
|
$recurrence->setDayOfMonth($dayOfMonth);
|
||||||
|
}
|
||||||
|
if (null !== $weekOfMonth) {
|
||||||
|
$recurrence->setWeekOfMonth($weekOfMonth);
|
||||||
|
}
|
||||||
|
if (null !== $endDate) {
|
||||||
|
$recurrence->setEndDate(new DateTimeImmutable($endDate));
|
||||||
|
}
|
||||||
|
if (null !== $maxOccurrences) {
|
||||||
|
$recurrence->setMaxOccurrences($maxOccurrences);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->setRecurrence($recurrence);
|
||||||
|
$this->entityManager->persist($recurrence);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->calDavService->syncTask($task);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'id' => $recurrence->getId(),
|
||||||
|
'type' => $recurrence->getType()?->value,
|
||||||
|
'interval' => $recurrence->getInterval(),
|
||||||
|
'daysOfWeek' => $recurrence->getDaysOfWeek(),
|
||||||
|
'dayOfMonth' => $recurrence->getDayOfMonth(),
|
||||||
|
'weekOfMonth' => $recurrence->getWeekOfMonth(),
|
||||||
|
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
|
||||||
|
'maxOccurrences' => $recurrence->getMaxOccurrences(),
|
||||||
|
'taskId' => $task->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ use App\Repository\TaskRepository;
|
|||||||
use App\Repository\TaskStatusRepository;
|
use App\Repository\TaskStatusRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -36,6 +38,7 @@ class CreateTaskTool
|
|||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
private readonly UserRepository $userRepository,
|
private readonly UserRepository $userRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(
|
public function __invoke(
|
||||||
@@ -48,6 +51,10 @@ class CreateTaskTool
|
|||||||
?int $assigneeId = null,
|
?int $assigneeId = null,
|
||||||
?int $groupId = null,
|
?int $groupId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
|
?string $scheduledStart = null,
|
||||||
|
?string $scheduledEnd = null,
|
||||||
|
?string $deadline = null,
|
||||||
|
?bool $syncToCalendar = null,
|
||||||
): string {
|
): string {
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
@@ -109,6 +116,18 @@ class CreateTaskTool
|
|||||||
$task->addTag($tag);
|
$task->addTag($tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (null !== $scheduledStart) {
|
||||||
|
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||||
|
}
|
||||||
|
if (null !== $scheduledEnd) {
|
||||||
|
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
|
||||||
|
}
|
||||||
|
if (null !== $deadline) {
|
||||||
|
$task->setDeadline(new DateTimeImmutable($deadline));
|
||||||
|
}
|
||||||
|
if (null !== $syncToCalendar) {
|
||||||
|
$task->setSyncToCalendar($syncToCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
|
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
|
||||||
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
||||||
@@ -116,19 +135,26 @@ class CreateTaskTool
|
|||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->calDavService->syncTask($task);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return json_encode([
|
return json_encode([
|
||||||
'id' => $task->getId(),
|
'id' => $task->getId(),
|
||||||
'number' => $task->getNumber(),
|
'number' => $task->getNumber(),
|
||||||
'title' => $task->getTitle(),
|
'title' => $task->getTitle(),
|
||||||
'description' => $task->getDescription(),
|
'description' => $task->getDescription(),
|
||||||
'status' => Serializer::status($task->getStatus()),
|
'status' => Serializer::status($task->getStatus()),
|
||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
'group' => Serializer::groupRef($task->getGroup()),
|
'group' => Serializer::groupRef($task->getGroup()),
|
||||||
'project' => Serializer::projectRef($project),
|
'project' => Serializer::projectRef($project),
|
||||||
'tags' => Serializer::tags($task->getTags()),
|
'tags' => Serializer::tags($task->getTags()),
|
||||||
'archived' => $task->isArchived(),
|
'archived' => $task->isArchived(),
|
||||||
|
'scheduledStart' => $task->getScheduledStart()?->format('c'),
|
||||||
|
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
|
||||||
|
'deadline' => $task->getDeadline()?->format('c'),
|
||||||
|
'syncToCalendar' => $task->isSyncToCalendar(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php
Normal file
66
src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
|
use App\Repository\TaskRecurrenceRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-task-recurrence', description: 'Delete a task recurrence pattern. Nullifies the recurrence on the active task and removes the recurring calendar event.')]
|
||||||
|
class DeleteTaskRecurrenceTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $recurrenceId): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
|
||||||
|
if (null === $recurrence) {
|
||||||
|
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tasks = $recurrence->getTasks()->toArray();
|
||||||
|
|
||||||
|
$eventUidToDelete = null;
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if (null !== $task->getCalendarEventUid()) {
|
||||||
|
$eventUidToDelete = $task->getCalendarEventUid();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$task->setRecurrence(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($recurrence);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (null !== $eventUidToDelete) {
|
||||||
|
$this->calDavService->deleteEvent($eventUidToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf('TaskRecurrence %d deleted.', $recurrenceId),
|
||||||
|
'tasksUpdated' => count($tasks),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Mcp\Tool\Task;
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -20,6 +21,7 @@ class DeleteTaskTool
|
|||||||
private readonly TaskRepository $taskRepository,
|
private readonly TaskRepository $taskRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(int $id): string
|
public function __invoke(int $id): string
|
||||||
@@ -35,9 +37,18 @@ class DeleteTaskTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
|
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
|
||||||
|
$eventUid = $task->getCalendarEventUid();
|
||||||
|
$todoUid = $task->getCalendarTodoUid();
|
||||||
$this->entityManager->remove($task);
|
$this->entityManager->remove($task);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (null !== $eventUid) {
|
||||||
|
$this->calDavService->deleteEvent($eventUid);
|
||||||
|
}
|
||||||
|
if (null !== $todoUid) {
|
||||||
|
$this->calDavService->deleteTodo($todoUid);
|
||||||
|
}
|
||||||
|
|
||||||
return json_encode([
|
return json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf('Task %s deleted.', $taskCode),
|
'message' => sprintf('Task %s deleted.', $taskCode),
|
||||||
|
|||||||
88
src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php
Normal file
88
src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
|
use App\Repository\TaskRecurrenceRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[McpTool(name: 'update-task-recurrence', description: 'Update an existing task recurrence pattern.')]
|
||||||
|
class UpdateTaskRecurrenceTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $recurrenceId,
|
||||||
|
?string $type = null,
|
||||||
|
?int $interval = null,
|
||||||
|
?array $daysOfWeek = null,
|
||||||
|
?int $dayOfMonth = null,
|
||||||
|
?int $weekOfMonth = null,
|
||||||
|
?string $endDate = null,
|
||||||
|
?int $maxOccurrences = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
|
||||||
|
if (null === $recurrence) {
|
||||||
|
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $type) {
|
||||||
|
$recurrence->setType(RecurrenceType::from($type));
|
||||||
|
}
|
||||||
|
if (null !== $interval) {
|
||||||
|
$recurrence->setInterval($interval);
|
||||||
|
}
|
||||||
|
if (null !== $daysOfWeek) {
|
||||||
|
$recurrence->setDaysOfWeek($daysOfWeek);
|
||||||
|
}
|
||||||
|
if (null !== $dayOfMonth) {
|
||||||
|
$recurrence->setDayOfMonth($dayOfMonth);
|
||||||
|
}
|
||||||
|
if (null !== $weekOfMonth) {
|
||||||
|
$recurrence->setWeekOfMonth($weekOfMonth);
|
||||||
|
}
|
||||||
|
if (null !== $endDate) {
|
||||||
|
$recurrence->setEndDate(new DateTimeImmutable($endDate));
|
||||||
|
}
|
||||||
|
if (null !== $maxOccurrences) {
|
||||||
|
$recurrence->setMaxOccurrences($maxOccurrences);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
foreach ($recurrence->getTasks() as $task) {
|
||||||
|
$this->calDavService->syncTask($task);
|
||||||
|
}
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'id' => $recurrence->getId(),
|
||||||
|
'type' => $recurrence->getType()?->value,
|
||||||
|
'interval' => $recurrence->getInterval(),
|
||||||
|
'daysOfWeek' => $recurrence->getDaysOfWeek(),
|
||||||
|
'dayOfMonth' => $recurrence->getDayOfMonth(),
|
||||||
|
'weekOfMonth' => $recurrence->getWeekOfMonth(),
|
||||||
|
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
|
||||||
|
'maxOccurrences' => $recurrence->getMaxOccurrences(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ use App\Repository\TaskRepository;
|
|||||||
use App\Repository\TaskStatusRepository;
|
use App\Repository\TaskStatusRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -33,6 +35,7 @@ class UpdateTaskTool
|
|||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
private readonly UserRepository $userRepository,
|
private readonly UserRepository $userRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
|
private readonly CalDavService $calDavService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(
|
public function __invoke(
|
||||||
@@ -46,6 +49,10 @@ class UpdateTaskTool
|
|||||||
?int $groupId = null,
|
?int $groupId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
?bool $archived = null,
|
?bool $archived = null,
|
||||||
|
?string $scheduledStart = null,
|
||||||
|
?string $scheduledEnd = null,
|
||||||
|
?string $deadline = null,
|
||||||
|
?bool $syncToCalendar = null,
|
||||||
): string {
|
): string {
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
@@ -114,22 +121,40 @@ class UpdateTaskTool
|
|||||||
if (null !== $archived) {
|
if (null !== $archived) {
|
||||||
$task->setArchived($archived);
|
$task->setArchived($archived);
|
||||||
}
|
}
|
||||||
|
if (null !== $scheduledStart) {
|
||||||
|
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||||
|
}
|
||||||
|
if (null !== $scheduledEnd) {
|
||||||
|
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
|
||||||
|
}
|
||||||
|
if (null !== $deadline) {
|
||||||
|
$task->setDeadline(new DateTimeImmutable($deadline));
|
||||||
|
}
|
||||||
|
if (null !== $syncToCalendar) {
|
||||||
|
$task->setSyncToCalendar($syncToCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->calDavService->syncTask($task);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return json_encode([
|
return json_encode([
|
||||||
'id' => $task->getId(),
|
'id' => $task->getId(),
|
||||||
'number' => $task->getNumber(),
|
'number' => $task->getNumber(),
|
||||||
'title' => $task->getTitle(),
|
'title' => $task->getTitle(),
|
||||||
'description' => $task->getDescription(),
|
'description' => $task->getDescription(),
|
||||||
'status' => Serializer::status($task->getStatus()),
|
'status' => Serializer::status($task->getStatus()),
|
||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
'group' => Serializer::groupRef($task->getGroup()),
|
'group' => Serializer::groupRef($task->getGroup()),
|
||||||
'project' => Serializer::projectRef($task->getProject()),
|
'project' => Serializer::projectRef($task->getProject()),
|
||||||
'tags' => Serializer::tags($task->getTags()),
|
'tags' => Serializer::tags($task->getTags()),
|
||||||
'archived' => $task->isArchived(),
|
'archived' => $task->isArchived(),
|
||||||
|
'scheduledStart' => $task->getScheduledStart()?->format('c'),
|
||||||
|
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
|
||||||
|
'deadline' => $task->getDeadline()?->format('c'),
|
||||||
|
'syncToCalendar' => $task->isSyncToCalendar(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class NotificationRepository extends ServiceEntityRepository
|
|||||||
->andWhere('n.isRead = false')
|
->andWhere('n.isRead = false')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->executeStatement()
|
->execute()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/Repository/TaskRecurrenceRepository.php
Normal file
17
src/Repository/TaskRecurrenceRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TaskRecurrence;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskRecurrenceRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, TaskRecurrence::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,4 +14,16 @@ class TaskStatusRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, TaskStatus::class);
|
parent::__construct($registry, TaskStatus::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findFirstNonFinal(): ?TaskStatus
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('s')
|
||||||
|
->where('s.isFinal = :final')
|
||||||
|
->setParameter('final', false)
|
||||||
|
->orderBy('s.position', 'ASC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/Repository/ZimbraConfigurationRepository.php
Normal file
26
src/Repository/ZimbraConfigurationRepository.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ZimbraConfiguration;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class ZimbraConfigurationRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ZimbraConfiguration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findSingleton(): ?ZimbraConfiguration
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('z')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
353
src/Service/CalDavService.php
Normal file
353
src/Service/CalDavService.php
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Task;
|
||||||
|
use App\Entity\TaskRecurrence;
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
|
use App\Repository\ZimbraConfigurationRepository;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Sabre\VObject\Component\VCalendar;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CalDavService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ZimbraConfigurationRepository $configRepository,
|
||||||
|
private readonly TokenEncryptor $tokenEncryptor,
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
|
||||||
|
return null !== $config && $config->isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection(): bool
|
||||||
|
{
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
|
||||||
|
if (null === $config || !$config->isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('PROPFIND', $this->getCalendarUrl(), [
|
||||||
|
'timeout' => 5,
|
||||||
|
'auth_basic' => [
|
||||||
|
$config->getUsername(),
|
||||||
|
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
|
||||||
|
],
|
||||||
|
'headers' => [
|
||||||
|
'Depth' => '0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$statusCode = $response->getStatusCode();
|
||||||
|
|
||||||
|
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('CalDAV connection test failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createEvent(Task $task): ?string
|
||||||
|
{
|
||||||
|
$uid = $this->generateUid();
|
||||||
|
$calendar = $this->buildEventCalendar($task, $uid);
|
||||||
|
|
||||||
|
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTodo(Task $task): ?string
|
||||||
|
{
|
||||||
|
$uid = $this->generateUid();
|
||||||
|
$calendar = $this->buildTodoCalendar($task, $uid);
|
||||||
|
|
||||||
|
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateEvent(Task $task): bool
|
||||||
|
{
|
||||||
|
$uid = $task->getCalendarEventUid();
|
||||||
|
|
||||||
|
if (null === $uid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendar = $this->buildEventCalendar($task, $uid);
|
||||||
|
|
||||||
|
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTodo(Task $task): bool
|
||||||
|
{
|
||||||
|
$uid = $task->getCalendarTodoUid();
|
||||||
|
|
||||||
|
if (null === $uid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendar = $this->buildTodoCalendar($task, $uid);
|
||||||
|
|
||||||
|
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteEvent(?string $uid): bool
|
||||||
|
{
|
||||||
|
if (null === $uid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteTodo(?string $uid): bool
|
||||||
|
{
|
||||||
|
if (null === $uid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncTask(Task $task): void
|
||||||
|
{
|
||||||
|
if (!$task->isSyncToCalendar()) {
|
||||||
|
$this->deleteEvent($task->getCalendarEventUid());
|
||||||
|
$this->deleteTodo($task->getCalendarTodoUid());
|
||||||
|
$task->setCalendarEventUid(null);
|
||||||
|
$task->setCalendarTodoUid(null);
|
||||||
|
$task->setCalendarSyncError(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasStart = null !== $task->getScheduledStart();
|
||||||
|
$hasDeadline = null !== $task->getDeadline();
|
||||||
|
|
||||||
|
if (!$hasStart && !$hasDeadline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncError = null;
|
||||||
|
|
||||||
|
if ($hasStart) {
|
||||||
|
if (null !== $task->getCalendarEventUid()) {
|
||||||
|
$success = $this->updateEvent($task);
|
||||||
|
} else {
|
||||||
|
$uid = $this->createEvent($task);
|
||||||
|
if (null !== $uid) {
|
||||||
|
$task->setCalendarEventUid($uid);
|
||||||
|
$success = true;
|
||||||
|
} else {
|
||||||
|
$success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
$syncError = 'Failed to sync event to calendar.';
|
||||||
|
}
|
||||||
|
} elseif (null !== $task->getCalendarEventUid()) {
|
||||||
|
$this->deleteEvent($task->getCalendarEventUid());
|
||||||
|
$task->setCalendarEventUid(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasDeadline) {
|
||||||
|
if (null !== $task->getCalendarTodoUid()) {
|
||||||
|
$success = $this->updateTodo($task);
|
||||||
|
} else {
|
||||||
|
$uid = $this->createTodo($task);
|
||||||
|
if (null !== $uid) {
|
||||||
|
$task->setCalendarTodoUid($uid);
|
||||||
|
$success = true;
|
||||||
|
} else {
|
||||||
|
$success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
$syncError = ($syncError ?? '').'Failed to sync todo to calendar.';
|
||||||
|
}
|
||||||
|
} elseif (null !== $task->getCalendarTodoUid()) {
|
||||||
|
$this->deleteTodo($task->getCalendarTodoUid());
|
||||||
|
$task->setCalendarTodoUid(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->setCalendarSyncError($syncError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildEventCalendar(Task $task, string $uid): VCalendar
|
||||||
|
{
|
||||||
|
$project = $task->getProject();
|
||||||
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
|
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
|
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||||
|
|
||||||
|
$vcalendar = new VCalendar();
|
||||||
|
$vcalendar->add('VEVENT', [
|
||||||
|
'UID' => $uid,
|
||||||
|
'SUMMARY' => $summary,
|
||||||
|
'DTSTART' => $task->getScheduledStart(),
|
||||||
|
'DTEND' => $task->getScheduledEnd(),
|
||||||
|
'DESCRIPTION' => $description,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recurrence = $task->getRecurrence();
|
||||||
|
|
||||||
|
if (null !== $recurrence) {
|
||||||
|
$vevent = $vcalendar->VEVENT;
|
||||||
|
$vevent->add('RRULE', $this->buildRRule($recurrence));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $vcalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTodoCalendar(Task $task, string $uid): VCalendar
|
||||||
|
{
|
||||||
|
$project = $task->getProject();
|
||||||
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
|
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
|
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||||
|
|
||||||
|
$vcalendar = new VCalendar();
|
||||||
|
$vcalendar->add('VTODO', [
|
||||||
|
'UID' => $uid,
|
||||||
|
'SUMMARY' => $summary,
|
||||||
|
'DUE' => $task->getDeadline(),
|
||||||
|
'DESCRIPTION' => $description,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $vcalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRRule(TaskRecurrence $recurrence): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$interval = $recurrence->getInterval();
|
||||||
|
|
||||||
|
match ($recurrence->getType()) {
|
||||||
|
RecurrenceType::Daily => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
|
||||||
|
RecurrenceType::Weekly => (function () use (&$parts, $interval, $recurrence): void {
|
||||||
|
$dayMap = $this->getDayMap();
|
||||||
|
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||||
|
$byDay = implode(',', array_map(fn (string $d) => $dayMap[$d] ?? $d, $daysOfWeek));
|
||||||
|
$rule = 'FREQ=WEEKLY;INTERVAL='.$interval;
|
||||||
|
if ('' !== $byDay) {
|
||||||
|
$rule .= ';BYDAY='.$byDay;
|
||||||
|
}
|
||||||
|
$parts[] = $rule;
|
||||||
|
})(),
|
||||||
|
RecurrenceType::Monthly => (function () use (&$parts, $interval, $recurrence): void {
|
||||||
|
$dayOfMonth = $recurrence->getDayOfMonth();
|
||||||
|
$weekOfMonth = $recurrence->getWeekOfMonth();
|
||||||
|
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||||
|
|
||||||
|
if (null !== $dayOfMonth) {
|
||||||
|
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYMONTHDAY='.$dayOfMonth;
|
||||||
|
} elseif (null !== $weekOfMonth && [] !== $daysOfWeek) {
|
||||||
|
$dayMap = $this->getDayMap();
|
||||||
|
$day = $dayMap[$daysOfWeek[0]] ?? $daysOfWeek[0];
|
||||||
|
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYDAY='.$weekOfMonth.$day;
|
||||||
|
} else {
|
||||||
|
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
RecurrenceType::Yearly => $parts[] = 'FREQ=YEARLY;INTERVAL='.$interval,
|
||||||
|
default => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
|
||||||
|
};
|
||||||
|
|
||||||
|
$rule = $parts[0] ?? 'FREQ=DAILY;INTERVAL=1';
|
||||||
|
|
||||||
|
$endDate = $recurrence->getEndDate();
|
||||||
|
$maxOccurrences = $recurrence->getMaxOccurrences();
|
||||||
|
|
||||||
|
if (null !== $endDate) {
|
||||||
|
$rule .= ';UNTIL='.$endDate->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z');
|
||||||
|
} elseif (null !== $maxOccurrences) {
|
||||||
|
$rule .= ';COUNT='.$maxOccurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCalendarUrl(): string
|
||||||
|
{
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
|
||||||
|
if (null === $config) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim((string) $config->getServerUrl(), '/').'/'.ltrim((string) $config->getCalendarPath(), '/').'/';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeRequest(string $method, string $url, ?string $body = null, string $contentType = 'text/calendar'): bool
|
||||||
|
{
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
|
||||||
|
if (null === $config) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$options = [
|
||||||
|
'timeout' => 5,
|
||||||
|
'auth_basic' => [
|
||||||
|
$config->getUsername(),
|
||||||
|
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $body) {
|
||||||
|
$options['headers'] = ['Content-Type' => $contentType];
|
||||||
|
$options['body'] = $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->httpClient->request($method, $url, $options);
|
||||||
|
$statusCode = $response->getStatusCode();
|
||||||
|
|
||||||
|
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error(sprintf('CalDAV %s request to %s failed: %s', $method, $url, $e->getMessage()));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUid(): string
|
||||||
|
{
|
||||||
|
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function getDayMap(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'monday' => 'MO',
|
||||||
|
'tuesday' => 'TU',
|
||||||
|
'wednesday' => 'WE',
|
||||||
|
'thursday' => 'TH',
|
||||||
|
'friday' => 'FR',
|
||||||
|
'saturday' => 'SA',
|
||||||
|
'sunday' => 'SU',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/Service/RecurrenceCalculator.php
Normal file
250
src/Service/RecurrenceCalculator.php
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Task;
|
||||||
|
use App\Entity\TaskRecurrence;
|
||||||
|
use App\Enum\RecurrenceType;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final class RecurrenceCalculator
|
||||||
|
{
|
||||||
|
public function getNextDate(Task $task): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$recurrence = $task->getRecurrence();
|
||||||
|
$scheduledStart = $task->getScheduledStart();
|
||||||
|
|
||||||
|
if (null === $recurrence || null === $scheduledStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasReachedEnd($recurrence)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $recurrence->getType();
|
||||||
|
$interval = $recurrence->getInterval();
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
RecurrenceType::Daily => $this->nextDaily($scheduledStart, $interval),
|
||||||
|
RecurrenceType::Weekly => $this->nextWeekly($scheduledStart, $interval, $recurrence->getDaysOfWeek() ?? []),
|
||||||
|
RecurrenceType::Monthly => $this->nextMonthly($scheduledStart, $interval, $recurrence),
|
||||||
|
RecurrenceType::Yearly => $this->nextYearly($scheduledStart, $interval),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextEnd(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$scheduledStart = $task->getScheduledStart();
|
||||||
|
$scheduledEnd = $task->getScheduledEnd();
|
||||||
|
|
||||||
|
if (null === $scheduledEnd || null === $scheduledStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $scheduledStart->diff($scheduledEnd);
|
||||||
|
|
||||||
|
return $nextStart->add($duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextDeadline(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$scheduledStart = $task->getScheduledStart();
|
||||||
|
$deadline = $task->getDeadline();
|
||||||
|
|
||||||
|
if (null === $deadline || null === $scheduledStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = $scheduledStart->diff($deadline);
|
||||||
|
|
||||||
|
return $nextStart->add($offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasReachedEnd(TaskRecurrence $recurrence): bool
|
||||||
|
{
|
||||||
|
$maxOccurrences = $recurrence->getMaxOccurrences();
|
||||||
|
|
||||||
|
if (null !== $maxOccurrences && $recurrence->getOccurrenceCount() >= $maxOccurrences) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = $recurrence->getEndDate();
|
||||||
|
|
||||||
|
if (null !== $endDate) {
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
if ($endDate < $today) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextDaily(DateTimeImmutable $start, int $interval): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $start->modify(sprintf('+%d days', $interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextWeekly(DateTimeImmutable $start, int $interval, array $daysOfWeek): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$candidate = $start->modify(sprintf('+%d weeks', $interval));
|
||||||
|
|
||||||
|
if ([] === $daysOfWeek) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayNumberMap = $this->getDayNumberMap();
|
||||||
|
|
||||||
|
// Collect target day numbers
|
||||||
|
$targetDayNumbers = [];
|
||||||
|
foreach ($daysOfWeek as $day) {
|
||||||
|
if (isset($dayNumberMap[$day])) {
|
||||||
|
$targetDayNumbers[] = $dayNumberMap[$day];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $targetDayNumbers) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($targetDayNumbers);
|
||||||
|
|
||||||
|
// Find the first matching day in the week starting from candidate
|
||||||
|
$weekStart = (int) $candidate->format('N'); // 1=Mon, 7=Sun
|
||||||
|
$candidateDayNum = $weekStart;
|
||||||
|
|
||||||
|
foreach ($targetDayNumbers as $targetDay) {
|
||||||
|
if ($targetDay >= $candidateDayNum) {
|
||||||
|
$diff = $targetDay - $candidateDayNum;
|
||||||
|
|
||||||
|
return $candidate->modify(sprintf('+%d days', $diff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap to next week's first matching day
|
||||||
|
$diff = 7 - $candidateDayNum + $targetDayNumbers[0];
|
||||||
|
|
||||||
|
return $candidate->modify(sprintf('+%d days', $diff));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextMonthly(DateTimeImmutable $start, int $interval, TaskRecurrence $recurrence): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$dayOfMonth = $recurrence->getDayOfMonth();
|
||||||
|
$weekOfMonth = $recurrence->getWeekOfMonth();
|
||||||
|
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||||
|
|
||||||
|
if (null !== $dayOfMonth) {
|
||||||
|
return $this->nextMonthlyByDayOfMonth($start, $interval, $dayOfMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $weekOfMonth && [] !== $daysOfWeek) {
|
||||||
|
return $this->nextMonthlyByWeekOfMonth($start, $interval, $weekOfMonth, $daysOfWeek[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: same day of month, interval months ahead
|
||||||
|
return $this->nextMonthlyByDayOfMonth($start, $interval, (int) $start->format('j'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextMonthlyByDayOfMonth(DateTimeImmutable $start, int $interval, int $dayOfMonth): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$year = (int) $start->format('Y');
|
||||||
|
$month = (int) $start->format('n');
|
||||||
|
|
||||||
|
$month += $interval;
|
||||||
|
|
||||||
|
while ($month > 12) {
|
||||||
|
$month -= 12;
|
||||||
|
++$year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle month overflow (e.g. dayOfMonth=31 in a 30-day month)
|
||||||
|
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
|
||||||
|
$day = min($dayOfMonth, $daysInMonth);
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf(
|
||||||
|
'%d-%02d-%02d %s',
|
||||||
|
$year,
|
||||||
|
$month,
|
||||||
|
$day,
|
||||||
|
$start->format('H:i:s'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextMonthlyByWeekOfMonth(DateTimeImmutable $start, int $interval, int $weekOfMonth, string $dayName): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$year = (int) $start->format('Y');
|
||||||
|
$month = (int) $start->format('n');
|
||||||
|
|
||||||
|
$month += $interval;
|
||||||
|
|
||||||
|
while ($month > 12) {
|
||||||
|
$month -= 12;
|
||||||
|
++$year;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayNumberMap = $this->getDayNumberMap();
|
||||||
|
$targetDayNum = $dayNumberMap[$dayName] ?? 1;
|
||||||
|
|
||||||
|
// Find the Nth occurrence of the target weekday in the target month
|
||||||
|
$firstOfMonth = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
|
||||||
|
$firstDayNum = (int) $firstOfMonth->format('N'); // 1=Mon, 7=Sun
|
||||||
|
|
||||||
|
// Days until first occurrence of target weekday
|
||||||
|
$daysToFirst = ($targetDayNum - $firstDayNum + 7) % 7;
|
||||||
|
$dayOfMonth = 1 + $daysToFirst + ($weekOfMonth - 1) * 7;
|
||||||
|
|
||||||
|
// Handle overflow (e.g. 5th occurrence that doesn't exist)
|
||||||
|
$daysInMonth = (int) $firstOfMonth->format('t');
|
||||||
|
|
||||||
|
if ($dayOfMonth > $daysInMonth) {
|
||||||
|
// Fall back to last occurrence
|
||||||
|
$dayOfMonth -= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf(
|
||||||
|
'%d-%02d-%02d %s',
|
||||||
|
$year,
|
||||||
|
$month,
|
||||||
|
$dayOfMonth,
|
||||||
|
$start->format('H:i:s'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextYearly(DateTimeImmutable $start, int $interval): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$year = (int) $start->format('Y') + $interval;
|
||||||
|
$month = (int) $start->format('n');
|
||||||
|
$day = (int) $start->format('j');
|
||||||
|
|
||||||
|
// Handle leap year: Feb 29 → Feb 28
|
||||||
|
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
|
||||||
|
$day = min($day, $daysInMonth);
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf(
|
||||||
|
'%d-%02d-%02d %s',
|
||||||
|
$year,
|
||||||
|
$month,
|
||||||
|
$day,
|
||||||
|
$start->format('H:i:s'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, int> */
|
||||||
|
private function getDayNumberMap(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'monday' => 1,
|
||||||
|
'tuesday' => 2,
|
||||||
|
'wednesday' => 3,
|
||||||
|
'thursday' => 4,
|
||||||
|
'friday' => 5,
|
||||||
|
'saturday' => 6,
|
||||||
|
'sunday' => 7,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/State/RecurrenceHandler.php
Normal file
105
src/State/RecurrenceHandler.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use App\Entity\Task;
|
||||||
|
use App\Repository\TaskRepository;
|
||||||
|
use App\Repository\TaskStatusRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use App\Service\RecurrenceCalculator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
final readonly class RecurrenceHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RecurrenceCalculator $calculator,
|
||||||
|
private TaskRepository $taskRepository,
|
||||||
|
private TaskStatusRepository $statusRepository,
|
||||||
|
private CalDavService $calDavService,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handleIfNeeded(Task $task, bool $wasAlreadyFinal): void
|
||||||
|
{
|
||||||
|
// Only trigger on STATUS CHANGE to isFinal
|
||||||
|
$currentStatus = $task->getStatus();
|
||||||
|
$isNowFinal = $currentStatus?->getIsFinal() ?? false;
|
||||||
|
|
||||||
|
if (!$isNowFinal || $wasAlreadyFinal) {
|
||||||
|
return; // No transition to final
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurrence = $task->getRecurrence();
|
||||||
|
if (null === $recurrence) {
|
||||||
|
return; // Not a recurring task
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->calculator->hasReachedEnd($recurrence)) {
|
||||||
|
return; // Recurrence is done
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextStart = $this->calculator->getNextDate($task);
|
||||||
|
if (null === $nextStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive current task, clear calendar UIDs
|
||||||
|
$savedEventUid = $task->getCalendarEventUid();
|
||||||
|
$task->setArchived(true);
|
||||||
|
$task->setCalendarEventUid(null);
|
||||||
|
$task->setCalendarTodoUid(null);
|
||||||
|
|
||||||
|
// Create new task with same fields
|
||||||
|
$newTask = new Task();
|
||||||
|
$newTask->setProject($task->getProject());
|
||||||
|
$newTask->setTitle($task->getTitle());
|
||||||
|
$newTask->setDescription($task->getDescription());
|
||||||
|
$newTask->setAssignee($task->getAssignee());
|
||||||
|
$newTask->setEffort($task->getEffort());
|
||||||
|
$newTask->setPriority($task->getPriority());
|
||||||
|
$newTask->setGroup($task->getGroup());
|
||||||
|
$newTask->setRecurrence($recurrence);
|
||||||
|
$newTask->setSyncToCalendar($task->isSyncToCalendar());
|
||||||
|
|
||||||
|
// Copy tags
|
||||||
|
foreach ($task->getTags() as $tag) {
|
||||||
|
$newTask->addTag($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set first non-final status
|
||||||
|
$firstStatus = $this->statusRepository->findFirstNonFinal();
|
||||||
|
$newTask->setStatus($firstStatus);
|
||||||
|
|
||||||
|
// Set recalculated dates
|
||||||
|
$newTask->setScheduledStart($nextStart);
|
||||||
|
$newTask->setScheduledEnd($this->calculator->getNextEnd($task, $nextStart));
|
||||||
|
$newTask->setDeadline($this->calculator->getNextDeadline($task, $nextStart));
|
||||||
|
|
||||||
|
// Copy calendar event UID (recurring VEVENT is shared)
|
||||||
|
$newTask->setCalendarEventUid($savedEventUid);
|
||||||
|
|
||||||
|
// Generate task number in transaction
|
||||||
|
$this->entityManager->wrapInTransaction(function () use ($newTask): void {
|
||||||
|
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject());
|
||||||
|
$newTask->setNumber($maxNumber + 1);
|
||||||
|
$this->entityManager->persist($newTask);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment occurrence count (with optimistic locking via @Version)
|
||||||
|
$recurrence->incrementOccurrenceCount();
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Sync new task's VTODO (new deadline) to Zimbra
|
||||||
|
if ($newTask->isSyncToCalendar() && $newTask->getDeadline()) {
|
||||||
|
$uid = $this->calDavService->createTodo($newTask);
|
||||||
|
if ($uid) {
|
||||||
|
$newTask->setCalendarTodoUid($uid);
|
||||||
|
$newTask->setCalendarSyncError(null);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/State/TaskCalendarProcessor.php
Normal file
75
src/State/TaskCalendarProcessor.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Task;
|
||||||
|
use App\Entity\TaskStatus;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<Task, Task>
|
||||||
|
*/
|
||||||
|
final readonly class TaskCalendarProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ProcessorInterface<Task, Task> $persistProcessor
|
||||||
|
* @param ProcessorInterface<Task, Task> $removeProcessor
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private ProcessorInterface $persistProcessor,
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
|
private ProcessorInterface $removeProcessor,
|
||||||
|
private CalDavService $calDavService,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private RecurrenceHandler $recurrenceHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if ($operation instanceof Delete) {
|
||||||
|
$eventUid = $data->getCalendarEventUid();
|
||||||
|
$todoUid = $data->getCalendarTodoUid();
|
||||||
|
|
||||||
|
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
if ($eventUid) {
|
||||||
|
$this->calDavService->deleteEvent($eventUid);
|
||||||
|
}
|
||||||
|
if ($todoUid) {
|
||||||
|
$this->calDavService->deleteTodo($todoUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect isFinal transition using Doctrine UnitOfWork.
|
||||||
|
// $data already has the NEW values (API Platform deserialized the PATCH).
|
||||||
|
// UnitOfWork originalEntityData stores the DB snapshot with entity references for relations.
|
||||||
|
$uow = $this->entityManager->getUnitOfWork();
|
||||||
|
$originalData = $uow->getOriginalEntityData($data);
|
||||||
|
$wasAlreadyFinal = false;
|
||||||
|
|
||||||
|
if (isset($originalData['status']) && $originalData['status'] instanceof TaskStatus) {
|
||||||
|
$wasAlreadyFinal = $originalData['status']->getIsFinal();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
// Sync to Zimbra after DB flush
|
||||||
|
$this->calDavService->syncTask($data);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Check for recurrence auto-creation (only on STATUS CHANGE to isFinal)
|
||||||
|
$this->recurrenceHandler->handleIfNeeded($data, $wasAlreadyFinal);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
|
use App\Service\CalDavService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
|
|||||||
private ProcessorInterface $persistProcessor,
|
private ProcessorInterface $persistProcessor,
|
||||||
private TaskRepository $taskRepository,
|
private TaskRepository $taskRepository,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
|
private CalDavService $calDavService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,12 +35,17 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
|
|||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
{
|
{
|
||||||
if ($operation instanceof Post && null !== $data->getProject()) {
|
if ($operation instanceof Post && null !== $data->getProject()) {
|
||||||
return $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
|
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
|
||||||
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
|
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
|
||||||
$data->setNumber($maxNumber + 1);
|
$data->setNumber($maxNumber + 1);
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->calDavService->syncTask($data);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|||||||
53
src/State/ZimbraSettingsProcessor.php
Normal file
53
src/State/ZimbraSettingsProcessor.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\ApiResource\ZimbraSettings;
|
||||||
|
use App\Entity\ZimbraConfiguration;
|
||||||
|
use App\Repository\ZimbraConfigurationRepository;
|
||||||
|
use App\Service\TokenEncryptor;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
final readonly class ZimbraSettingsProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private ZimbraConfigurationRepository $configRepository,
|
||||||
|
private TokenEncryptor $tokenEncryptor,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
|
||||||
|
{
|
||||||
|
assert($data instanceof ZimbraSettings);
|
||||||
|
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
if (null === $config) {
|
||||||
|
$config = new ZimbraConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
$config->setServerUrl($data->serverUrl);
|
||||||
|
$config->setUsername($data->username);
|
||||||
|
$config->setCalendarPath($data->calendarPath);
|
||||||
|
$config->setEnabled($data->enabled);
|
||||||
|
|
||||||
|
if (null !== $data->password && '' !== $data->password) {
|
||||||
|
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($config);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$result = new ZimbraSettings();
|
||||||
|
$result->serverUrl = $config->getServerUrl();
|
||||||
|
$result->username = $config->getUsername();
|
||||||
|
$result->calendarPath = $config->getCalendarPath();
|
||||||
|
$result->enabled = $config->isEnabled();
|
||||||
|
$result->hasPassword = $config->hasPassword();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/State/ZimbraSettingsProvider.php
Normal file
33
src/State/ZimbraSettingsProvider.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\ZimbraSettings;
|
||||||
|
use App\Repository\ZimbraConfigurationRepository;
|
||||||
|
|
||||||
|
final readonly class ZimbraSettingsProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ZimbraConfigurationRepository $configRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
|
||||||
|
{
|
||||||
|
$config = $this->configRepository->findSingleton();
|
||||||
|
$dto = new ZimbraSettings();
|
||||||
|
|
||||||
|
if (null !== $config) {
|
||||||
|
$dto->serverUrl = $config->getServerUrl();
|
||||||
|
$dto->username = $config->getUsername();
|
||||||
|
$dto->calendarPath = $config->getCalendarPath();
|
||||||
|
$dto->enabled = $config->isEnabled();
|
||||||
|
$dto->hasPassword = $config->hasPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/State/ZimbraTestConnectionProvider.php
Normal file
37
src/State/ZimbraTestConnectionProvider.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\ZimbraTestConnection;
|
||||||
|
use App\Service\CalDavService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class ZimbraTestConnectionProvider implements ProviderInterface, ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CalDavService $calDavService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
|
||||||
|
{
|
||||||
|
return new ZimbraTestConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
|
||||||
|
{
|
||||||
|
$result = new ZimbraTestConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result->success = $this->calDavService->testConnection();
|
||||||
|
} catch (Throwable) {
|
||||||
|
$result->success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user