Compare commits
80 Commits
v0.1.0
...
7e7e373231
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e7e373231 | ||
|
|
517511177c | ||
|
|
56275a9ebe | ||
|
|
dbae1f7536 | ||
|
|
d5d6452cf2 | ||
|
|
e6bbe66d42 | ||
|
|
0c4363d32b | ||
|
|
81d0433653 | ||
|
|
5057ef45c8 | ||
|
|
c097849dad | ||
|
|
7fe434fa07 | ||
|
|
4e391e2f57 | ||
|
|
84c85b3322 | ||
|
|
91ffb82e44 | ||
|
|
96a9f988c4 | ||
|
|
2c2ca0a8b6 | ||
|
|
e98d952871 | ||
|
|
8503111a4b | ||
|
|
6801dae0f2 | ||
|
|
73d0c7b4fa | ||
|
|
b76fd589cc | ||
|
|
20a5dca6d5 | ||
|
|
60b5aad0a4 | ||
|
|
3e6f4ecc7a | ||
|
|
dac493b76d | ||
|
|
37a6cb5558 | ||
|
|
cf84883530 | ||
|
|
ae8654d9ca | ||
|
|
9d5008a21d | ||
|
|
cbe3408b72 | ||
|
|
16c9b845a6 | ||
|
|
df29214509 | ||
|
|
5b8b4716df | ||
|
|
f06842729d | ||
|
|
1f74509475 | ||
|
|
0bf01cfb27 | ||
|
|
2ffdaafd08 | ||
|
|
33f2bcc393 | ||
|
|
f9d4de3e33 | ||
| c886506791 | |||
| 1efa0fa9ca | |||
| d28f385918 | |||
| ae3eeed7d9 | |||
| 7ee1be63b3 | |||
| c15a10b36f | |||
| 049275fd96 | |||
| a9ba2f3815 | |||
| 7484ce3e45 | |||
| d4c5660ba6 | |||
| 576922200c | |||
| 74116506db | |||
| cf021d6136 | |||
| 1e07eb1d64 | |||
| fa0adfde88 | |||
| e9ca888971 | |||
| 2299d66a9f | |||
| 66bb94fc98 | |||
| 50ae9ef549 | |||
| 95450e3b5f | |||
| bb332aa7e8 | |||
| fd6d0afb24 | |||
| 71e6e83c82 | |||
| 2f746ebce4 | |||
| 91da21d16b | |||
| 8c56ee6dd7 | |||
| 81797e10c0 | |||
| c7b1e62037 | |||
| ac11690ad4 | |||
| 0a7856b37c | |||
| 1d50e5dcb3 | |||
| b240dc6fc4 | |||
| 64ae634297 | |||
| bb45066013 | |||
| 9ba49cd29c | |||
| 5f57b377fa | |||
| b5efb54f71 | |||
| de7c2c25cd | |||
| b5dbab7dab | |||
| b56d2f6526 | |||
| 0621388ee6 |
23
CLAUDE.md
23
CLAUDE.md
@@ -5,28 +5,29 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||||
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
|
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
|
||||||
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER`
|
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER`
|
||||||
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
|
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Entity/ # Entités Doctrine
|
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskType, TaskGroup, TimeEntry)
|
||||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||||
src/State/ # Providers et Processors API Platform
|
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor)
|
||||||
src/Repository/ # Repositories Doctrine
|
src/Repository/ # Repositories Doctrine
|
||||||
src/DataFixtures/ # Fixtures
|
src/DataFixtures/ # Fixtures
|
||||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||||
migrations/ # Migrations Doctrine
|
migrations/ # Migrations Doctrine
|
||||||
|
docs/plans/ # Plans d'implémentation
|
||||||
frontend/ # App Nuxt 4
|
frontend/ # App Nuxt 4
|
||||||
frontend/pages/ # Pages
|
frontend/pages/ # Pages (index, login, clients, projects, projects/[id], projects/[id]/groups, projects/[id]/statuses, time-tracking, admin)
|
||||||
frontend/layouts/ # Layouts (pas "layout")
|
frontend/layouts/ # Layouts (pas "layout")
|
||||||
frontend/components/ # Composants Vue
|
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, *Drawer, TaskCard, Admin*Tab, ProjectStatusTab, ProjectGroupTab, SidebarLink, SidebarTimer, TimeEntry*, TimeTrackingCalendar, ConfirmDeleteStatusModal)
|
||||||
frontend/composables/# Composables (useApi, etc.)
|
frontend/composables/# Composables (useApi, useAppVersion)
|
||||||
frontend/stores/ # Stores Pinia
|
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||||
frontend/services/ # Services API (auth, etc.)
|
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-types, users, time-entries)
|
||||||
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/)
|
||||||
```
|
```
|
||||||
@@ -35,10 +36,14 @@ frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make start # Démarrer les containers
|
make start # Démarrer les containers
|
||||||
|
make stop # Arrêter les containers
|
||||||
|
make restart # Redémarrer les containers
|
||||||
make install # Install complet (composer, migrations, fixtures, build Nuxt)
|
make install # Install complet (composer, migrations, fixtures, build Nuxt)
|
||||||
make reset # Tout supprimer et réinstaller (supprime la BDD)
|
make reset # Tout supprimer et réinstaller (supprime la BDD)
|
||||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||||
make shell # Shell dans le container PHP
|
make shell # Shell dans le container PHP
|
||||||
|
make shell-root # Shell root dans le container PHP
|
||||||
|
make cache-clear # Vider le cache Symfony
|
||||||
make migration-migrate # Lancer les migrations
|
make migration-migrate # Lancer les migrations
|
||||||
make fixtures # Charger les fixtures
|
make fixtures # Charger les fixtures
|
||||||
make db-reset # Reset BDD + migrations + fixtures
|
make db-reset # Reset BDD + migrations + fixtures
|
||||||
@@ -69,7 +74,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
|
|
||||||
- TypeScript strict
|
- TypeScript strict
|
||||||
- Composable `useApi()` pour tous les appels API (gère cookies, erreurs, toasts, i18n)
|
- Composable `useApi()` pour tous les appels API (gère cookies, erreurs, toasts, i18n)
|
||||||
- Store Pinia pour l'auth (`useAuthStore`)
|
- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui), `useTimerStore` (timer)
|
||||||
- Middleware global `auth.global.ts` protège les routes
|
- Middleware global `auth.global.ts` protège les routes
|
||||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
|
|||||||
14
TODO.md
Normal file
14
TODO.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## Fonctionnalités à implémenter
|
||||||
|
|
||||||
|
- [ ] Page liste des groupes de tâches par projet
|
||||||
|
- [ ] Archivage des tickets (définir le mécanisme : statut archivé, soft delete, ou flag dédié)
|
||||||
|
|
||||||
|
## Bugs / Corrections
|
||||||
|
|
||||||
|
- [ ] Logout ne fonctionne pas correctement
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- [ ] Gérer les permissions des groupes utilisateurs Symfony
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Hello API Platform
|
title: Hello API Platform
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
formats:
|
||||||
|
jsonld: ['application/ld+json']
|
||||||
|
json: ['application/json']
|
||||||
|
patch_formats:
|
||||||
|
json: ['application/merge-patch+json']
|
||||||
defaults:
|
defaults:
|
||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
1354
docs/plans/2026-03-09-clients-projects-crud.md
Normal file
1354
docs/plans/2026-03-09-clients-projects-crud.md
Normal file
File diff suppressed because it is too large
Load Diff
2421
docs/plans/2026-03-09-task-management.md
Normal file
2421
docs/plans/2026-03-09-task-management.md
Normal file
File diff suppressed because it is too large
Load Diff
1829
docs/superpowers/plans/2026-03-10-time-tracking.md
Normal file
1829
docs/superpowers/plans/2026-03-10-time-tracking.md
Normal file
File diff suppressed because it is too large
Load Diff
1180
docs/superpowers/plans/2026-03-12-task-archiving.md
Normal file
1180
docs/superpowers/plans/2026-03-12-task-archiving.md
Normal file
File diff suppressed because it is too large
Load Diff
138
docs/superpowers/specs/2026-03-12-task-archiving-design.md
Normal file
138
docs/superpowers/specs/2026-03-12-task-archiving-design.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Feature: Archivage de tickets et de groupes
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Permettre d'archiver des tickets individuels (quand leur statut est final) et des groupes entiers (quand tous leurs tickets sont en statut final). Les éléments archivés disparaissent de la vue kanban et sont consultables via une page dédiée "Archives" dans le projet.
|
||||||
|
|
||||||
|
## Modèle de données
|
||||||
|
|
||||||
|
### TaskStatus — ajout `isFinal`
|
||||||
|
|
||||||
|
- Nouveau champ `isFinal: bool` (default `false`)
|
||||||
|
- Mis à `true` sur le statut "Terminé" dans les fixtures
|
||||||
|
- Exposé en lecture et écriture via API Platform (groupes de sérialisation `task_status:read`, `task_status:write`, et `task:read`)
|
||||||
|
- Permet d'identifier dynamiquement quels statuts autorisent l'archivage
|
||||||
|
|
||||||
|
### Task — ajout `archived`
|
||||||
|
|
||||||
|
- Nouveau champ `archived: bool` (default `false`)
|
||||||
|
- Filtre API Platform `BooleanFilter` sur `archived` pour requêter `?archived=false` ou `?archived=true`
|
||||||
|
- Le kanban charge les tickets avec `archived=false`
|
||||||
|
- La page archives charge les tickets avec `archived=true`
|
||||||
|
|
||||||
|
### TaskGroup — ajout `archived`
|
||||||
|
|
||||||
|
- Nouveau champ `archived: bool` (default `false`)
|
||||||
|
- Filtre API Platform `BooleanFilter` sur `archived`
|
||||||
|
- Le kanban et le filtre groupe n'affichent que les groupes `archived=false`
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Une migration Doctrine unique pour les 3 champs (`task_status.is_final`, `task.archived`, `task_group.archived`).
|
||||||
|
|
||||||
|
## Backend — logique métier
|
||||||
|
|
||||||
|
### Archivage de groupe (bulk)
|
||||||
|
|
||||||
|
L'archivage d'un groupe est une opération frontend multi-appels :
|
||||||
|
1. PATCH chaque ticket du groupe avec `{ archived: true }`
|
||||||
|
2. PATCH le groupe avec `{ archived: true }`
|
||||||
|
|
||||||
|
Pas de endpoint custom côté backend — on réutilise les PATCH existants.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
L'archivage suit le modèle de permissions existant : les opérations PATCH sur Task et TaskGroup requièrent `ROLE_ADMIN`. Pas de règle supplémentaire.
|
||||||
|
|
||||||
|
### Pas de validation backend sur `isFinal`
|
||||||
|
|
||||||
|
La règle "archiver seulement si statut final" est appliquée côté frontend (visibilité du bouton). Pas de State Processor dédié — cohérent avec le reste de l'app qui ne valide pas les transitions de statut côté serveur.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### TaskDrawer — archivage et modale suppression
|
||||||
|
|
||||||
|
**Bouton "Archiver"** :
|
||||||
|
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
|
||||||
|
- PATCH `{ archived: true }` sur le ticket
|
||||||
|
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
|
||||||
|
- Ferme le drawer et rafraîchit la liste des tickets
|
||||||
|
|
||||||
|
**Bouton "Désarchiver"** :
|
||||||
|
- Visible quand on consulte un ticket archivé (depuis la page archives)
|
||||||
|
- PATCH `{ archived: false }`
|
||||||
|
- Ferme le drawer et rafraîchit la page archives
|
||||||
|
|
||||||
|
**Modale de confirmation de suppression** :
|
||||||
|
- Déclenchée au clic sur "Supprimer" dans le TaskDrawer
|
||||||
|
- Message : "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||||
|
- Deux boutons : "Annuler" / "Supprimer" (style destructif, rouge)
|
||||||
|
- Suit le pattern existant de `ConfirmDeleteStatusModal`
|
||||||
|
|
||||||
|
### Page Archives — `/projects/[id]/archives`
|
||||||
|
|
||||||
|
- Nouveau sous-onglet "Archives" dans la navigation projet (à côté de "Groupes")
|
||||||
|
- Liste des tickets archivés du projet (`archived=true`)
|
||||||
|
- Colonnes affichées : numéro, titre, statut, groupe, assigné
|
||||||
|
- Clic sur un ticket → ouvre le TaskDrawer (avec bouton "Désarchiver")
|
||||||
|
- Filtre par groupe possible
|
||||||
|
|
||||||
|
### Page Groupes — archivage de groupes
|
||||||
|
|
||||||
|
**Vue par défaut** : affiche uniquement les groupes non archivés.
|
||||||
|
|
||||||
|
**Toggle "Voir les groupes archivés"** : bascule pour afficher les groupes archivés.
|
||||||
|
|
||||||
|
**Bouton "Archiver" sur un groupe** :
|
||||||
|
- Visible uniquement si le groupe a au moins un ticket ET que **tous** ses tickets ont un statut `isFinal: true` (un ticket sans statut bloque l'archivage)
|
||||||
|
- Archive tous les tickets du groupe puis le groupe lui-même (appels PATCH séquentiels)
|
||||||
|
- Rafraîchit la liste
|
||||||
|
|
||||||
|
**Bouton "Désarchiver" sur un groupe archivé** :
|
||||||
|
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
|
||||||
|
- Rafraîchit la liste
|
||||||
|
|
||||||
|
### Admin — toggle `isFinal` sur les statuts
|
||||||
|
|
||||||
|
- Ajout d'un checkbox/toggle "Statut final" dans l'AdminStatusTab (création et édition de statuts)
|
||||||
|
- Permet aux admins de configurer quels statuts sont considérés comme finaux
|
||||||
|
|
||||||
|
### Kanban — filtrage
|
||||||
|
|
||||||
|
- Le filtre groupe dans le dropdown n'affiche que les groupes `archived=false`
|
||||||
|
- Les tickets `archived=true` sont exclus du kanban
|
||||||
|
|
||||||
|
### Time tracking
|
||||||
|
|
||||||
|
- Les entrées de temps liées à des tickets archivés restent visibles dans les vues time-tracking (pas de changement)
|
||||||
|
|
||||||
|
## DTOs
|
||||||
|
|
||||||
|
### TaskStatus
|
||||||
|
|
||||||
|
Ajout du champ `isFinal: boolean` dans les types `TaskStatus` et `TaskStatusWrite`.
|
||||||
|
|
||||||
|
### Task
|
||||||
|
|
||||||
|
Ajout du champ `archived: boolean` dans les types `Task` et `TaskWrite`.
|
||||||
|
|
||||||
|
### TaskGroup
|
||||||
|
|
||||||
|
Ajout du champ `archived: boolean` dans les types `TaskGroup` et `TaskGroupWrite`.
|
||||||
|
|
||||||
|
## Traductions (i18n)
|
||||||
|
|
||||||
|
Clés à ajouter dans `fr.json` :
|
||||||
|
- `task.archive` / `task.unarchive`
|
||||||
|
- `task.delete_confirm_title` / `task.delete_confirm_message`
|
||||||
|
- `group.archive` / `group.unarchive`
|
||||||
|
- `group.show_archived` / `group.hide_archived`
|
||||||
|
- `project.tabs.archives`
|
||||||
|
- `status.is_final`
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
- Historique/date d'archivage (pourra être ajouté plus tard avec un champ `archivedAt`)
|
||||||
|
- Archivage automatique (cron/scheduler)
|
||||||
|
- Archivage en masse depuis la page archives
|
||||||
|
- Verrouillage des tickets archivés (modification de statut, etc.)
|
||||||
97
frontend/components/admin/AdminClientTab.vue
Normal file
97
frontend/components/admin/AdminClientTab.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="clients"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun client trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<template #cell-email="{ item }">
|
||||||
|
{{ item.email ?? '-' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-address="{ item }">
|
||||||
|
{{ formatAddress(item) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-phone="{ item }">
|
||||||
|
{{ item.phone ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<ClientDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:client="selectedClient"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
|
import { useClientService } from '~/services/clients'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'name', label: 'Nom', primary: true },
|
||||||
|
{ key: 'email', label: 'Email', class: 'text-primary-500' },
|
||||||
|
{ key: 'address', label: 'Adresse', class: 'text-neutral-700' },
|
||||||
|
{ key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getAll, remove } = useClientService()
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedClient = ref<Client | null>(null)
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
clients.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedClient.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(client: Client) {
|
||||||
|
selectedClient.value = client
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(client: Client): string {
|
||||||
|
return [client.street, client.postalCode, client.city]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadClients()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadClients()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadClients()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
78
frontend/components/admin/AdminEffortTab.vue
Normal file
78
frontend/components/admin/AdminEffortTab.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un effort
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun effort trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskEffortDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'label', label: 'Libellé', primary: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getAll, remove } = useTaskEffortService()
|
||||||
|
const items = ref<TaskEffort[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<TaskEffort | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TaskEffort) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
86
frontend/components/admin/AdminPriorityTab.vue
Normal file
86
frontend/components/admin/AdminPriorityTab.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter une priorité
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucune priorité trouvée."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<template #cell-color="{ item }">
|
||||||
|
<span
|
||||||
|
class="inline-block h-6 w-6 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<TaskPriorityDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'label', label: 'Libellé', primary: true },
|
||||||
|
{ key: 'color', label: 'Couleur' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getAll, remove } = useTaskPriorityService()
|
||||||
|
const items = ref<TaskPriority[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<TaskPriority | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TaskPriority) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
139
frontend/components/admin/AdminStatusTab.vue
Normal file
139
frontend/components/admin/AdminStatusTab.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un statut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun statut trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="requestDelete"
|
||||||
|
>
|
||||||
|
<template #cell-color="{ item }">
|
||||||
|
<span
|
||||||
|
class="inline-block h-6 w-6 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<TaskStatusDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDeleteStatusModal
|
||||||
|
v-model="confirmModalOpen"
|
||||||
|
:status-label="statusToDelete?.label ?? ''"
|
||||||
|
:task-count="affectedTaskCount"
|
||||||
|
:available-statuses="reassignTargets"
|
||||||
|
@confirm="onConfirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'label', label: 'Libellé', primary: true },
|
||||||
|
{ key: 'color', label: 'Couleur' },
|
||||||
|
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusService = useTaskStatusService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
|
||||||
|
const items = ref<TaskStatus[]>([])
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<TaskStatus | null>(null)
|
||||||
|
const confirmModalOpen = ref(false)
|
||||||
|
const statusToDelete = ref<TaskStatus | null>(null)
|
||||||
|
|
||||||
|
const affectedTaskCount = computed(() => {
|
||||||
|
if (!statusToDelete.value) return 0
|
||||||
|
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const reassignTargets = computed(() => {
|
||||||
|
if (!statusToDelete.value) return items.value
|
||||||
|
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const [statuses, allTasks] = await Promise.all([
|
||||||
|
statusService.getAll(),
|
||||||
|
taskService.getAll(),
|
||||||
|
])
|
||||||
|
items.value = statuses
|
||||||
|
tasks.value = allTasks
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TaskStatus) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDelete(item: TaskStatus) {
|
||||||
|
statusToDelete.value = item
|
||||||
|
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
||||||
|
if (count === 0) {
|
||||||
|
await statusService.remove(item.id)
|
||||||
|
await loadItems()
|
||||||
|
} else {
|
||||||
|
confirmModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmDelete(targetStatusId: number | null) {
|
||||||
|
if (!statusToDelete.value) return
|
||||||
|
|
||||||
|
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
||||||
|
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
||||||
|
)
|
||||||
|
|
||||||
|
await statusService.remove(statusToDelete.value.id)
|
||||||
|
confirmModalOpen.value = false
|
||||||
|
statusToDelete.value = null
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
86
frontend/components/admin/AdminTagTab.vue
Normal file
86
frontend/components/admin/AdminTagTab.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun tag trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<template #cell-color="{ item }">
|
||||||
|
<span
|
||||||
|
class="inline-block h-6 w-6 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<TaskTagDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'label', label: 'Libellé', primary: true },
|
||||||
|
{ key: 'color', label: 'Couleur' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getAll, remove } = useTaskTagService()
|
||||||
|
const items = ref<TaskTag[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<TaskTag | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TaskTag) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
89
frontend/components/admin/AdminUserTab.vue
Normal file
89
frontend/components/admin/AdminUserTab.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un utilisateur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun utilisateur trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<template #cell-roles="{ item }">
|
||||||
|
<span
|
||||||
|
v-for="role in item.roles"
|
||||||
|
:key="role"
|
||||||
|
class="mr-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-700"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<UserDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'username', label: "Nom d'utilisateur", primary: true },
|
||||||
|
{ key: 'roles', label: 'Rôles' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getAll, remove } = useUserService()
|
||||||
|
const items = ref<UserData[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<UserData | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: UserData) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
137
frontend/components/client/ClientDrawer.vue
Normal file
137
frontend/components/client/ClientDrawer.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
label="Nom"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||||
|
@blur="touched.name = true"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.email"
|
||||||
|
label="Email"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.phone"
|
||||||
|
label="Téléphone"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.street"
|
||||||
|
label="Rue"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.city"
|
||||||
|
label="Ville"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.postalCode"
|
||||||
|
label="Code Postal"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||||
|
import { useClientService } from '~/services/clients'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
client: Client | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.client)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
street: '',
|
||||||
|
city: '',
|
||||||
|
postalCode: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
name: false,
|
||||||
|
email: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.client) {
|
||||||
|
form.name = props.client.name ?? ''
|
||||||
|
form.email = props.client.email ?? ''
|
||||||
|
form.phone = props.client.phone ?? ''
|
||||||
|
form.street = props.client.street ?? ''
|
||||||
|
form.city = props.client.city ?? ''
|
||||||
|
form.postalCode = props.client.postalCode ?? ''
|
||||||
|
} else {
|
||||||
|
form.name = ''
|
||||||
|
form.email = ''
|
||||||
|
form.phone = ''
|
||||||
|
form.street = ''
|
||||||
|
form.city = ''
|
||||||
|
form.postalCode = ''
|
||||||
|
}
|
||||||
|
touched.name = false
|
||||||
|
touched.email = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useClientService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.name = true
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: ClientWrite = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
phone: form.phone.trim() || null,
|
||||||
|
street: form.street.trim() || null,
|
||||||
|
city: form.city.trim() || null,
|
||||||
|
postalCode: form.postalCode.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.client) {
|
||||||
|
await update(props.client.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
140
frontend/components/project/ProjectDrawer.vue
Normal file
140
frontend/components/project/ProjectDrawer.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.code"
|
||||||
|
label="Code"
|
||||||
|
input-class="w-full uppercase"
|
||||||
|
:disabled="isEditing"
|
||||||
|
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
|
||||||
|
@blur="touched.code = true"
|
||||||
|
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
label="Titre"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
|
||||||
|
@blur="touched.name = true"
|
||||||
|
/>
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="form.description"
|
||||||
|
label="Description"
|
||||||
|
:size="3"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.clientId"
|
||||||
|
:options="clientOptions"
|
||||||
|
label="Client"
|
||||||
|
empty-option-label="Aucun client"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<ColorPicker v-model="form.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
project: Project | null
|
||||||
|
clients: Client[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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.project)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#222783',
|
||||||
|
clientId: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
code: false,
|
||||||
|
name: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clientOptions = computed(() =>
|
||||||
|
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.project) {
|
||||||
|
form.code = props.project.code ?? ''
|
||||||
|
form.name = props.project.name ?? ''
|
||||||
|
form.description = props.project.description ?? ''
|
||||||
|
form.color = props.project.color ?? '#222783'
|
||||||
|
form.clientId = props.project.client?.id ?? null
|
||||||
|
} else {
|
||||||
|
form.code = ''
|
||||||
|
form.name = ''
|
||||||
|
form.description = ''
|
||||||
|
form.color = '#222783'
|
||||||
|
form.clientId = null
|
||||||
|
}
|
||||||
|
touched.code = false
|
||||||
|
touched.name = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useProjectService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.name = true
|
||||||
|
touched.code = true
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: ProjectWrite = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
color: form.color,
|
||||||
|
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.project) {
|
||||||
|
await update(props.project.id, payload)
|
||||||
|
} else {
|
||||||
|
payload.code = form.code.trim()
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
169
frontend/components/project/ProjectGroupTab.vue
Normal file
169
frontend/components/project/ProjectGroupTab.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
||||||
|
@click="showArchived = !showArchived"
|
||||||
|
>
|
||||||
|
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!showArchived"
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun groupe trouvé."
|
||||||
|
:deletable="!showArchived"
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<template #cell-color="{ item }">
|
||||||
|
<span
|
||||||
|
class="inline-block h-6 w-6 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell-description="{ item }">
|
||||||
|
{{ item.description ?? '—' }}
|
||||||
|
</template>
|
||||||
|
<template #actions="{ item }">
|
||||||
|
<button
|
||||||
|
v-if="!showArchived && canArchiveGroup(item)"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||||
|
@click.stop="handleArchive(item)"
|
||||||
|
>
|
||||||
|
{{ $t('archive.archiveButton') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showArchived"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||||
|
@click.stop="handleUnarchive(item)"
|
||||||
|
>
|
||||||
|
{{ $t('archive.unarchiveButton') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<TaskGroupDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:group="selectedItem"
|
||||||
|
:project-id="projectId"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updated'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'title', label: 'Titre', primary: true },
|
||||||
|
{ key: 'color', label: 'Couleur' },
|
||||||
|
{ key: 'description', label: 'Description', class: 'max-w-xs truncate text-neutral-700' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const groupService = useTaskGroupService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
|
||||||
|
const allGroups = ref<TaskGroup[]>([])
|
||||||
|
const activeTasks = ref<Task[]>([])
|
||||||
|
const archivedTasks = ref<Task[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<TaskGroup | null>(null)
|
||||||
|
const showArchived = ref(false)
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
allGroups.value.filter(g => showArchived.value ? g.archived : !g.archived)
|
||||||
|
)
|
||||||
|
|
||||||
|
function canArchiveGroup(group: TaskGroup): boolean {
|
||||||
|
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||||
|
if (groupTasks.length === 0) return false
|
||||||
|
return groupTasks.every(t => t.status?.isFinal === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const [g, t, at] = await Promise.all([
|
||||||
|
groupService.getByProject(props.projectId),
|
||||||
|
taskService.getByProject(props.projectId),
|
||||||
|
taskService.getByProjectArchived(props.projectId),
|
||||||
|
])
|
||||||
|
allGroups.value = g
|
||||||
|
activeTasks.value = t
|
||||||
|
archivedTasks.value = at
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TaskGroup) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await groupService.remove(id)
|
||||||
|
await loadItems()
|
||||||
|
emit('updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive(group: TaskGroup) {
|
||||||
|
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||||
|
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: true })))
|
||||||
|
await groupService.update(group.id, { archived: true })
|
||||||
|
await loadItems()
|
||||||
|
emit('updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnarchive(group: TaskGroup) {
|
||||||
|
const groupTasks = archivedTasks.value.filter(t => t.group?.id === group.id)
|
||||||
|
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: false })))
|
||||||
|
await groupService.update(group.id, { archived: false })
|
||||||
|
await loadItems()
|
||||||
|
emit('updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
emit('updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
92
frontend/components/task/TaskCard.vue
Normal file
92
frontend/components/task/TaskCard.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||||
|
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 transition-colors"
|
||||||
|
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||||
|
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||||
|
>
|
||||||
|
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center gap-1.5">
|
||||||
|
<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>
|
||||||
|
<span
|
||||||
|
v-if="task.assignee"
|
||||||
|
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||||
|
:title="task.assignee.username"
|
||||||
|
>
|
||||||
|
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ml-auto 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 = defineProps<{
|
||||||
|
task: Task
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
|
const isTimerOnTask = computed(() => {
|
||||||
|
const entry = timerStore.activeEntry
|
||||||
|
if (!entry?.task) return false
|
||||||
|
const entryTaskId = typeof entry.task === 'string'
|
||||||
|
? entry.task
|
||||||
|
: (entry.task['@id'] ?? entry.task.id)
|
||||||
|
const taskId = props.task['@id'] ?? props.task.id
|
||||||
|
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPlay() {
|
||||||
|
timerStore.startFromTask(props.task)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(event: DragEvent) {
|
||||||
|
event.dataTransfer!.effectAllowed = 'move'
|
||||||
|
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||||
|
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(event: DragEvent) {
|
||||||
|
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
327
frontend/components/task/TaskDrawer.vue
Normal file
327
frontend/components/task/TaskDrawer.vue
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
|
||||||
|
<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 any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.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>
|
||||||
90
frontend/components/task/TaskEffortDrawer.vue
Normal file
90
frontend/components/task/TaskEffortDrawer.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||||
|
@blur="touched.label = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
|
||||||
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: TaskEffort | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
label: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
label: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.label = props.item.label ?? ''
|
||||||
|
} else {
|
||||||
|
form.label = ''
|
||||||
|
}
|
||||||
|
touched.label = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useTaskEffortService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.label = true
|
||||||
|
if (!form.label.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskEffortWrite = {
|
||||||
|
label: form.label.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
108
frontend/components/task/TaskGroupDrawer.vue
Normal file
108
frontend/components/task/TaskGroupDrawer.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<ColorPicker v-model="form.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
group: TaskGroup | null
|
||||||
|
projectId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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.group)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
color: '#222783',
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
title: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.group) {
|
||||||
|
form.title = props.group.title ?? ''
|
||||||
|
form.description = props.group.description ?? ''
|
||||||
|
form.color = props.group.color ?? '#222783'
|
||||||
|
} else {
|
||||||
|
form.title = ''
|
||||||
|
form.description = ''
|
||||||
|
form.color = '#222783'
|
||||||
|
}
|
||||||
|
touched.title = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useTaskGroupService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.title = true
|
||||||
|
if (!form.title.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskGroupWrite = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
color: form.color,
|
||||||
|
project: `/api/projects/${props.projectId}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.group) {
|
||||||
|
await update(props.group.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
97
frontend/components/task/TaskPriorityDrawer.vue
Normal file
97
frontend/components/task/TaskPriorityDrawer.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||||
|
@blur="touched.label = true"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<ColorPicker v-model="form.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
|
||||||
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: TaskPriority | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
label: '',
|
||||||
|
color: '#222783',
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
label: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.label = props.item.label ?? ''
|
||||||
|
form.color = props.item.color ?? '#222783'
|
||||||
|
} else {
|
||||||
|
form.label = ''
|
||||||
|
form.color = '#222783'
|
||||||
|
}
|
||||||
|
touched.label = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useTaskPriorityService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.label = true
|
||||||
|
if (!form.label.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskPriorityWrite = {
|
||||||
|
label: form.label.trim(),
|
||||||
|
color: form.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
123
frontend/components/task/TaskStatusDrawer.vue
Normal file
123
frontend/components/task/TaskStatusDrawer.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||||
|
@blur="touched.label = true"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.position"
|
||||||
|
label="Position"
|
||||||
|
input-class="w-full"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<ColorPicker v-model="form.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="isFinal"
|
||||||
|
v-model="form.isFinal"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label for="isFinal" class="text-sm font-medium text-neutral-700">
|
||||||
|
{{ $t('archive.statusFinal') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: TaskStatus | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
label: '',
|
||||||
|
position: '0',
|
||||||
|
color: '#222783',
|
||||||
|
isFinal: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
label: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.label = props.item.label ?? ''
|
||||||
|
form.position = String(props.item.position ?? 0)
|
||||||
|
form.color = props.item.color ?? '#222783'
|
||||||
|
form.isFinal = props.item.isFinal ?? false
|
||||||
|
} else {
|
||||||
|
form.label = ''
|
||||||
|
form.position = '0'
|
||||||
|
form.color = '#222783'
|
||||||
|
form.isFinal = false
|
||||||
|
}
|
||||||
|
touched.label = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useTaskStatusService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.label = true
|
||||||
|
if (!form.label.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskStatusWrite = {
|
||||||
|
label: form.label.trim(),
|
||||||
|
position: Number(form.position),
|
||||||
|
color: form.color,
|
||||||
|
isFinal: form.isFinal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
97
frontend/components/task/TaskTagDrawer.vue
Normal file
97
frontend/components/task/TaskTagDrawer.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||||
|
@blur="touched.label = true"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<ColorPicker v-model="form.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||||
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: TaskTag | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
label: '',
|
||||||
|
color: '#222783',
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
label: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.label = props.item.label ?? ''
|
||||||
|
form.color = props.item.color ?? '#222783'
|
||||||
|
} else {
|
||||||
|
form.label = ''
|
||||||
|
form.color = '#222783'
|
||||||
|
}
|
||||||
|
touched.label = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useTaskTagService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.label = true
|
||||||
|
if (!form.label.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskTagWrite = {
|
||||||
|
label: form.label.trim(),
|
||||||
|
color: form.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
241
frontend/components/time-tracking/TimeEntryBlock.vue
Normal file
241
frontend/components/time-tracking/TimeEntryBlock.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="blockEl"
|
||||||
|
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
||||||
|
:style="blockStyle"
|
||||||
|
:class="{ 'opacity-40': isDragSource }"
|
||||||
|
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Resize handle top (outside block) -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
||||||
|
style="bottom: 100%"
|
||||||
|
@mousedown.stop.prevent="onResizeTopStart"
|
||||||
|
>
|
||||||
|
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-1.5 py-0.5 h-full overflow-hidden">
|
||||||
|
<!-- Full display: title + project + type dot + duration -->
|
||||||
|
<template v-if="sizeLevel >= 3">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||||
|
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
||||||
|
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
v-for="tag in entry.tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
{{ tag.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Medium: title + duration -->
|
||||||
|
<template v-else-if="sizeLevel === 2">
|
||||||
|
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||||
|
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Small: title only -->
|
||||||
|
<template v-else-if="sizeLevel === 1">
|
||||||
|
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Tiny: just a colored bar, no text -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resize handle bottom (outside block) -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
||||||
|
style="top: 100%"
|
||||||
|
@mousedown.stop.prevent="onResizeBottomStart"
|
||||||
|
>
|
||||||
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: TimeEntry
|
||||||
|
hourHeight: number
|
||||||
|
dayStartHour: number
|
||||||
|
isDragSource?: boolean
|
||||||
|
columnIndex?: number
|
||||||
|
totalColumns?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click', entry: TimeEntry): void
|
||||||
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||||
|
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
|
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const blockEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const startDate = computed(() => new Date(props.entry.startedAt))
|
||||||
|
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
||||||
|
|
||||||
|
const resizeTopDeltaMinutes = ref(0)
|
||||||
|
const resizeBottomDeltaMinutes = ref(0)
|
||||||
|
|
||||||
|
const duration = computed(() => {
|
||||||
|
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
||||||
|
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
const heightPx = computed(() => {
|
||||||
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||||
|
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
||||||
|
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Responsive content levels based on block height
|
||||||
|
// 3 = full (title + project + types + duration)
|
||||||
|
// 2 = medium (title + duration)
|
||||||
|
// 1 = small (title only)
|
||||||
|
// 0 = tiny (colored bar only)
|
||||||
|
const sizeLevel = computed(() => {
|
||||||
|
const h = heightPx.value
|
||||||
|
if (h >= 50) return 3
|
||||||
|
if (h >= 35) return 2
|
||||||
|
if (h >= 20) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const blockStyle = computed(() => {
|
||||||
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||||
|
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||||
|
const bgColor = props.entry.project?.color ?? '#94a3b8'
|
||||||
|
|
||||||
|
const col = props.columnIndex ?? 0
|
||||||
|
const total = props.totalColumns ?? 1
|
||||||
|
const gapPx = 2
|
||||||
|
const leftPercent = (col / total) * 100
|
||||||
|
const widthPercent = (1 / total) * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${topPx}px`,
|
||||||
|
height: `${heightPx.value}px`,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||||
|
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Click / Drag detection ---
|
||||||
|
let mouseDownPos = { x: 0, y: 0 }
|
||||||
|
let mouseDownHandled = false
|
||||||
|
|
||||||
|
function onMouseDown(event: MouseEvent) {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
||||||
|
|
||||||
|
mouseDownPos = { x: event.clientX, y: event.clientY }
|
||||||
|
mouseDownHandled = false
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMoveDetect)
|
||||||
|
document.addEventListener('mouseup', onMouseUpDetect)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMoveDetect(event: MouseEvent) {
|
||||||
|
const dx = event.clientX - mouseDownPos.x
|
||||||
|
const dy = event.clientY - mouseDownPos.y
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
||||||
|
mouseDownHandled = true
|
||||||
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||||
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||||
|
|
||||||
|
const rect = blockEl.value!.getBoundingClientRect()
|
||||||
|
emit('moveStart', {
|
||||||
|
entry: props.entry,
|
||||||
|
offsetY: mouseDownPos.y - rect.top,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUpDetect() {
|
||||||
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||||
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||||
|
if (!mouseDownHandled) {
|
||||||
|
emit('click', props.entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resize bottom (change stoppedAt) ---
|
||||||
|
function onResizeBottomStart(event: MouseEvent) {
|
||||||
|
const startY = event.clientY
|
||||||
|
resizeBottomDeltaMinutes.value = 0
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 's-resize'
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
const delta = e.clientY - startY
|
||||||
|
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
|
||||||
|
const finalDelta = resizeBottomDeltaMinutes.value
|
||||||
|
resizeBottomDeltaMinutes.value = 0
|
||||||
|
|
||||||
|
if (finalDelta !== 0) {
|
||||||
|
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
||||||
|
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resize top (change startedAt) ---
|
||||||
|
function onResizeTopStart(event: MouseEvent) {
|
||||||
|
const startY = event.clientY
|
||||||
|
resizeTopDeltaMinutes.value = 0
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'n-resize'
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
const delta = e.clientY - startY
|
||||||
|
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
|
||||||
|
const finalDelta = resizeTopDeltaMinutes.value
|
||||||
|
resizeTopDeltaMinutes.value = 0
|
||||||
|
|
||||||
|
if (finalDelta !== 0) {
|
||||||
|
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
||||||
|
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
89
frontend/components/time-tracking/TimeEntryContextMenu.vue
Normal file
89
frontend/components/time-tracking/TimeEntryContextMenu.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuEl"
|
||||||
|
class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
|
||||||
|
:style="{ top: `${y}px`, left: `${x}px` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="entry"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="onCopy"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:content-copy" size="16" />
|
||||||
|
Copier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canPaste"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="onPaste"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:content-paste" size="16" />
|
||||||
|
Coller
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="entry"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="16" />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
entry?: TimeEntry | null
|
||||||
|
canPaste: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'copy', entry: TimeEntry): void
|
||||||
|
(e: 'paste'): void
|
||||||
|
(e: 'delete', entry: TimeEntry): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menuEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onCopy() {
|
||||||
|
if (props.entry) emit('copy', props.entry)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste() {
|
||||||
|
emit('paste')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete() {
|
||||||
|
if (props.entry) emit('delete', props.entry)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(event: MouseEvent) {
|
||||||
|
if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) {
|
||||||
|
setTimeout(() => document.addEventListener('click', onClickOutside), 0)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', onClickOutside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
266
frontend/components/time-tracking/TimeEntryDrawer.vue
Normal file
266
frontend/components/time-tracking/TimeEntryDrawer.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Que fais-tu ?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||||
|
<input
|
||||||
|
v-model="form.date"
|
||||||
|
type="date"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Début</label>
|
||||||
|
<input
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="time"
|
||||||
|
step="60"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Fin</label>
|
||||||
|
<input
|
||||||
|
v-model="form.endTime"
|
||||||
|
type="time"
|
||||||
|
step="60"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="durationLabel"
|
||||||
|
class="rounded-md bg-neutral-100 px-3 py-2 text-center text-sm font-semibold text-neutral-600 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ durationLabel }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.userId"
|
||||||
|
:options="userOptions"
|
||||||
|
label="Utilisateur"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.projectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
label="Projet"
|
||||||
|
empty-option-label="— Aucun —"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-semibold 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="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 transition"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
entry?: TimeEntry | null
|
||||||
|
prefillStartedAt?: string | null
|
||||||
|
users: UserData[]
|
||||||
|
projects: Project[]
|
||||||
|
tags: TaskTag[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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.entry)
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
userId: authStore.user?.id ?? null as number | null,
|
||||||
|
projectId: null as number | null,
|
||||||
|
tagIds: [] as number[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
props.projects.map(p => ({ label: p.name, value: p.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const durationLabel = computed(() => {
|
||||||
|
if (!form.startTime || !form.endTime) return ''
|
||||||
|
const [sh, sm] = form.startTime.split(':').map(Number) as [number, number]
|
||||||
|
const [eh, em] = form.endTime.split(':').map(Number) as [number, number]
|
||||||
|
const diff = (eh * 60 + em) - (sh * 60 + sm)
|
||||||
|
if (diff <= 0) return ''
|
||||||
|
const h = Math.floor(diff / 60)
|
||||||
|
const m = diff % 60
|
||||||
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
const idx = form.tagIds.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
form.tagIds.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
form.tagIds.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const offset = d.getTimezoneOffset()
|
||||||
|
const local = new Date(d.getTime() - offset * 60000)
|
||||||
|
return local.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const offset = d.getTimezoneOffset()
|
||||||
|
const local = new Date(d.getTime() - offset * 60000)
|
||||||
|
return local.toISOString().slice(11, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toISO(date: string, time: string): string {
|
||||||
|
return new Date(`${date}T${time}`).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateForm(entry: TimeEntry | null | undefined) {
|
||||||
|
if (entry) {
|
||||||
|
form.title = entry.title ?? ''
|
||||||
|
form.description = entry.description ?? ''
|
||||||
|
form.date = toLocalDate(entry.startedAt)
|
||||||
|
form.startTime = toLocalTime(entry.startedAt)
|
||||||
|
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
|
||||||
|
form.userId = entry.user?.id ?? authStore.user?.id ?? null
|
||||||
|
form.projectId = entry.project?.id ?? null
|
||||||
|
form.tagIds = entry.tags?.map(t => t.id) ?? []
|
||||||
|
} else {
|
||||||
|
form.title = ''
|
||||||
|
form.description = ''
|
||||||
|
form.date = props.prefillStartedAt ? toLocalDate(props.prefillStartedAt) : new Date().toISOString().slice(0, 10)
|
||||||
|
form.startTime = props.prefillStartedAt ? toLocalTime(props.prefillStartedAt) : ''
|
||||||
|
form.endTime = ''
|
||||||
|
form.userId = authStore.user?.id ?? null
|
||||||
|
form.projectId = null
|
||||||
|
form.tagIds = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||||
|
if (open) {
|
||||||
|
populateForm(entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
if (!props.entry) return
|
||||||
|
const { remove } = useTimeEntryService()
|
||||||
|
await remove(props.entry.id)
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!form.date || !form.startTime || !form.endTime) return
|
||||||
|
|
||||||
|
const { create, update } = 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}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.entry) {
|
||||||
|
await update(props.entry.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
104
frontend/components/time-tracking/TimeEntryList.vue
Normal file
104
frontend/components/time-tracking/TimeEntryList.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
||||||
|
Aucune activité pour cette période
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="entry in sortedEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||||
|
@click="emit('editEntry', entry)"
|
||||||
|
>
|
||||||
|
<!-- Color bar -->
|
||||||
|
<div
|
||||||
|
class="h-10 w-1 shrink-0 rounded-full"
|
||||||
|
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||||
|
{{ entry.title || 'Sans titre' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="tag in entry.tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: tag.color }"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
|
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
||||||
|
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time info -->
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<div class="text-sm font-semibold tabular-nums text-neutral-900">
|
||||||
|
{{ formatDuration(entry) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs tabular-nums text-neutral-400">
|
||||||
|
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="hidden shrink-0 text-xs text-neutral-400 sm:block">
|
||||||
|
{{ formatDate(entry.startedAt) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete action -->
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||||
|
title="Supprimer"
|
||||||
|
@click.stop="emit('deleteEntry', entry)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entries: TimeEntry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'editEntry', entry: TimeEntry): void
|
||||||
|
(e: 'deleteEntry', entry: TimeEntry): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortedEntries = computed(() => {
|
||||||
|
return [...props.entries].sort((a, b) => {
|
||||||
|
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDuration(entry: TimeEntry): string {
|
||||||
|
const start = new Date(entry.startedAt).getTime()
|
||||||
|
const end = entry.stoppedAt ? new Date(entry.stoppedAt).getTime() : Date.now()
|
||||||
|
const diff = end - start
|
||||||
|
const h = Math.floor(diff / 3600000)
|
||||||
|
const m = Math.floor((diff % 3600000) / 60000)
|
||||||
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
546
frontend/components/time-tracking/TimeTrackingCalendar.vue
Normal file
546
frontend/components/time-tracking/TimeTrackingCalendar.vue
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="calendarEl" class="relative rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<!-- Day headers -->
|
||||||
|
<div
|
||||||
|
class="sticky z-20 flex border-b border-neutral-200 bg-white"
|
||||||
|
:style="{ top: `${stickyOffset}px` }"
|
||||||
|
>
|
||||||
|
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.dateStr"
|
||||||
|
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||||
|
{{ day.dayNum }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||||
|
{{ day.label }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid body -->
|
||||||
|
<div ref="gridBodyEl" class="relative flex">
|
||||||
|
<!-- Hour labels -->
|
||||||
|
<div class="w-16 shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="hour in hours"
|
||||||
|
:key="hour"
|
||||||
|
class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
|
||||||
|
:style="{ height: `${hourHeight}px` }"
|
||||||
|
>
|
||||||
|
{{ String(hour).padStart(2, '0') }} : 00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day columns -->
|
||||||
|
<div
|
||||||
|
v-for="(day, dayIndex) in days"
|
||||||
|
:key="day.dateStr"
|
||||||
|
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||||
|
class="relative flex-1 border-r border-neutral-100"
|
||||||
|
@click="onClickGrid($event, day)"
|
||||||
|
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||||
|
>
|
||||||
|
<!-- Hour row lines -->
|
||||||
|
<div
|
||||||
|
v-for="hour in hours"
|
||||||
|
:key="hour"
|
||||||
|
class="border-b border-neutral-100"
|
||||||
|
:style="{ height: `${hourHeight}px` }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Time entry blocks with overlap columns -->
|
||||||
|
<TimeEntryBlock
|
||||||
|
v-for="layout in layoutForDay(day.dateStr)"
|
||||||
|
:key="layout.entry.id"
|
||||||
|
:entry="layout.entry"
|
||||||
|
:hour-height="hourHeight"
|
||||||
|
:day-start-hour="0"
|
||||||
|
:is-drag-source="dragState?.entryId === layout.entry.id"
|
||||||
|
:column-index="layout.columnIndex"
|
||||||
|
:total-columns="layout.totalColumns"
|
||||||
|
@click="emit('editEntry', $event)"
|
||||||
|
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
||||||
|
@resize="(ent, newStart, newStop) => emit('resizeEntry', ent, newStart, newStop)"
|
||||||
|
@move-start="(payload) => onMoveStart(payload, dayIndex)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Overflow indicators for dense groups -->
|
||||||
|
<div
|
||||||
|
v-for="overflow in overflowsForDay(day.dateStr)"
|
||||||
|
:key="`overflow-${overflow.topPx}`"
|
||||||
|
class="absolute right-1 z-20 rounded bg-neutral-700 px-1.5 py-0.5 text-[10px] font-semibold text-white cursor-pointer hover:bg-neutral-600 transition"
|
||||||
|
:style="{ top: `${overflow.topPx}px` }"
|
||||||
|
@click.stop="openOverflowPopover(dayIndex, overflow)"
|
||||||
|
>
|
||||||
|
+{{ overflow.count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overflow popover -->
|
||||||
|
<div
|
||||||
|
v-if="overflowPopover && overflowPopover.dayIndex === dayIndex"
|
||||||
|
class="absolute z-30 w-48 rounded-lg border border-neutral-200 bg-white p-2 shadow-xl"
|
||||||
|
:style="{ top: `${overflowPopover.topPx}px`, right: '4px' }"
|
||||||
|
>
|
||||||
|
<div class="mb-1 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold text-neutral-600">{{ overflowPopover.entries.length }} entrées masquées</span>
|
||||||
|
<button class="text-neutral-400 hover:text-neutral-600 text-xs" @click="overflowPopover = null">×</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="entry in overflowPopover.entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-neutral-50 transition"
|
||||||
|
@click.stop="emit('editEntry', entry); overflowPopover = null"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-3 w-3 shrink-0 rounded-sm"
|
||||||
|
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
|
||||||
|
<div class="text-[10px] text-neutral-500">
|
||||||
|
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drag ghost preview -->
|
||||||
|
<div
|
||||||
|
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
||||||
|
class="absolute left-1 right-1 rounded-md px-2 py-1 text-xs text-white shadow-lg pointer-events-none ring-2 ring-white/60 transition-[top] duration-75"
|
||||||
|
:style="{
|
||||||
|
top: `${dragState.ghostTopPx}px`,
|
||||||
|
height: `${dragState.ghostHeightPx}px`,
|
||||||
|
backgroundColor: dragState.color,
|
||||||
|
opacity: 0.75,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="font-semibold truncate">{{ dragState.title }}</div>
|
||||||
|
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entries: TimeEntry[]
|
||||||
|
startDate: Date
|
||||||
|
viewMode: 'week' | 'day'
|
||||||
|
stickyOffset?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'editEntry', entry: TimeEntry): void
|
||||||
|
(e: 'createEntry', startedAt: string): void
|
||||||
|
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
|
(e: 'resizeEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hourHeight = 60
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||||
|
|
||||||
|
const calendarEl = ref<HTMLElement | null>(null)
|
||||||
|
const gridBodyEl = ref<HTMLElement | null>(null)
|
||||||
|
const dayColumnEls = ref<HTMLElement[]>([])
|
||||||
|
const stickyOffset = computed(() => props.stickyOffset ?? 0)
|
||||||
|
|
||||||
|
function getScrollParent(): HTMLElement | null {
|
||||||
|
let el = calendarEl.value?.parentElement
|
||||||
|
while (el) {
|
||||||
|
if (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'visible') return el
|
||||||
|
el = el.parentElement
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to current hour on mount
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!calendarEl.value) return
|
||||||
|
const scrollParent = getScrollParent()
|
||||||
|
if (!scrollParent) return
|
||||||
|
const now = new Date()
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const calendarTop = calendarEl.value.offsetTop
|
||||||
|
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
||||||
|
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Days computation ---
|
||||||
|
const days = computed(() => {
|
||||||
|
const count = props.viewMode === 'week' ? 7 : 1
|
||||||
|
const result = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const d = new Date(props.startDate)
|
||||||
|
d.setDate(d.getDate() + i)
|
||||||
|
const dateStr = toDateStr(d)
|
||||||
|
const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
||||||
|
const totalMs = dayEntries.reduce((sum, e) => {
|
||||||
|
if (!e.stoppedAt) return sum
|
||||||
|
return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
|
||||||
|
}, 0)
|
||||||
|
const totalH = Math.floor(totalMs / 3600000)
|
||||||
|
const totalM = Math.floor((totalMs % 3600000) / 60000)
|
||||||
|
const totalS = Math.floor((totalMs % 60000) / 1000)
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: new Date(d),
|
||||||
|
dateStr,
|
||||||
|
dayNum: d.getDate(),
|
||||||
|
label: dayLabels[d.getDay()],
|
||||||
|
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function toDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(d: Date): boolean {
|
||||||
|
return toDateStr(d) === toDateStr(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function entriesForDay(dateStr: string): TimeEntry[] {
|
||||||
|
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Overlap layout computation ---
|
||||||
|
const MAX_VISIBLE_COLUMNS = 4
|
||||||
|
|
||||||
|
interface EntryLayout {
|
||||||
|
entry: TimeEntry
|
||||||
|
columnIndex: number
|
||||||
|
totalColumns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverflowIndicator {
|
||||||
|
topPx: number
|
||||||
|
count: number
|
||||||
|
hiddenEntries: TimeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryMinutes(entry: TimeEntry): { start: number; end: number } {
|
||||||
|
const s = new Date(entry.startedAt)
|
||||||
|
const startMin = s.getHours() * 60 + s.getMinutes()
|
||||||
|
const e = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
|
||||||
|
const endMin = e.getHours() * 60 + e.getMinutes()
|
||||||
|
return { start: startMin, end: Math.max(endMin, startMin + 15) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeOverlapLayout(dayEntries: TimeEntry[]): { layouts: EntryLayout[]; overflows: OverflowIndicator[] } {
|
||||||
|
if (dayEntries.length === 0) return { layouts: [], overflows: [] }
|
||||||
|
|
||||||
|
// Sort by start time, then by duration (longest first)
|
||||||
|
const sorted = [...dayEntries].sort((a, b) => {
|
||||||
|
const aM = getEntryMinutes(a)
|
||||||
|
const bM = getEntryMinutes(b)
|
||||||
|
if (aM.start !== bM.start) return aM.start - bM.start
|
||||||
|
return (bM.end - bM.start) - (aM.end - aM.start)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group overlapping entries into clusters
|
||||||
|
const clusters: TimeEntry[][] = []
|
||||||
|
let currentCluster: TimeEntry[] = []
|
||||||
|
let clusterEnd = 0
|
||||||
|
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const { start, end } = getEntryMinutes(entry)
|
||||||
|
if (currentCluster.length === 0 || start < clusterEnd) {
|
||||||
|
currentCluster.push(entry)
|
||||||
|
clusterEnd = Math.max(clusterEnd, end)
|
||||||
|
} else {
|
||||||
|
clusters.push(currentCluster)
|
||||||
|
currentCluster = [entry]
|
||||||
|
clusterEnd = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentCluster.length > 0) clusters.push(currentCluster)
|
||||||
|
|
||||||
|
const layouts: EntryLayout[] = []
|
||||||
|
const overflows: OverflowIndicator[] = []
|
||||||
|
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
// Assign columns within this cluster
|
||||||
|
const colEnds: number[] = []
|
||||||
|
|
||||||
|
const clusterAssignments: { entry: TimeEntry; col: number }[] = []
|
||||||
|
|
||||||
|
for (const entry of cluster) {
|
||||||
|
const { start, end } = getEntryMinutes(entry)
|
||||||
|
// Find first column where this entry fits
|
||||||
|
let placed = false
|
||||||
|
for (let c = 0; c < colEnds.length; c++) {
|
||||||
|
if (colEnds[c]! <= start) {
|
||||||
|
colEnds[c] = end
|
||||||
|
clusterAssignments.push({ entry, col: c })
|
||||||
|
placed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
clusterAssignments.push({ entry, col: colEnds.length })
|
||||||
|
colEnds.push(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalColumns = Math.min(colEnds.length, MAX_VISIBLE_COLUMNS)
|
||||||
|
let hasOverflow = false
|
||||||
|
|
||||||
|
for (const { entry, col } of clusterAssignments) {
|
||||||
|
if (col < MAX_VISIBLE_COLUMNS) {
|
||||||
|
layouts.push({
|
||||||
|
entry,
|
||||||
|
columnIndex: col,
|
||||||
|
totalColumns,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
hasOverflow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOverflow) {
|
||||||
|
const hidden = clusterAssignments.filter((a) => a.col >= MAX_VISIBLE_COLUMNS)
|
||||||
|
const firstEntry = cluster[0]!
|
||||||
|
const { start } = getEntryMinutes(firstEntry)
|
||||||
|
overflows.push({
|
||||||
|
topPx: (start / 60) * hourHeight,
|
||||||
|
count: hidden.length,
|
||||||
|
hiddenEntries: hidden.map((a) => a.entry),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { layouts, overflows }
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutCache = computed(() => {
|
||||||
|
const cache = new Map<string, { layouts: EntryLayout[]; overflows: OverflowIndicator[] }>()
|
||||||
|
for (const day of days.value) {
|
||||||
|
const dayEntries = entriesForDay(day.dateStr)
|
||||||
|
cache.set(day.dateStr, computeOverlapLayout(dayEntries))
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
})
|
||||||
|
|
||||||
|
function layoutForDay(dateStr: string): EntryLayout[] {
|
||||||
|
return layoutCache.value.get(dateStr)?.layouts ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function overflowsForDay(dateStr: string): OverflowIndicator[] {
|
||||||
|
return layoutCache.value.get(dateStr)?.overflows ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Overflow popover ---
|
||||||
|
interface OverflowPopoverState {
|
||||||
|
dayIndex: number
|
||||||
|
topPx: number
|
||||||
|
entries: TimeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflowPopover = ref<OverflowPopoverState | null>(null)
|
||||||
|
|
||||||
|
function openOverflowPopover(dayIndex: number, overflow: OverflowIndicator) {
|
||||||
|
overflowPopover.value = {
|
||||||
|
dayIndex,
|
||||||
|
topPx: overflow.topPx,
|
||||||
|
entries: overflow.hiddenEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnappedMinutesFromY(y: number): number {
|
||||||
|
return Math.max(0, Math.min(23 * 60 + 45, Math.round((y / hourHeight) * 60 / 15) * 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(totalMinutes: number): string {
|
||||||
|
const h = Math.floor(totalMinutes / 60)
|
||||||
|
const m = totalMinutes % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Click to create ---
|
||||||
|
let dragEndTime = 0
|
||||||
|
|
||||||
|
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
|
||||||
|
// Suppress click right after drag end
|
||||||
|
if (Date.now() - dragEndTime < 200) return
|
||||||
|
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
const y = event.clientY - rect.top
|
||||||
|
const minutes = getSnappedMinutesFromY(y)
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
const d = new Date(day.date)
|
||||||
|
d.setHours(h, m, 0, 0)
|
||||||
|
emit('createEntry', d.toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenuGrid(event: MouseEvent, _day: { date: Date; dateStr: string }) {
|
||||||
|
emit('contextmenu', event, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag to move ---
|
||||||
|
interface DragState {
|
||||||
|
entryId: number
|
||||||
|
entry: TimeEntry
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
durationMinutes: number
|
||||||
|
ghostHeightPx: number
|
||||||
|
offsetY: number
|
||||||
|
targetDayIndex: number
|
||||||
|
ghostTopPx: number
|
||||||
|
snappedMinutes: number
|
||||||
|
timeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragState = ref<DragState | null>(null)
|
||||||
|
let autoScrollActive = false
|
||||||
|
let lastMouseEvent: MouseEvent | null = null
|
||||||
|
|
||||||
|
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
|
||||||
|
const entry = payload.entry
|
||||||
|
const startMinutes = new Date(entry.startedAt).getHours() * 60 + new Date(entry.startedAt).getMinutes()
|
||||||
|
const endMinutes = entry.stoppedAt
|
||||||
|
? new Date(entry.stoppedAt).getHours() * 60 + new Date(entry.stoppedAt).getMinutes()
|
||||||
|
: startMinutes + 60
|
||||||
|
const durationMinutes = endMinutes - startMinutes
|
||||||
|
|
||||||
|
dragState.value = {
|
||||||
|
entryId: entry.id,
|
||||||
|
entry,
|
||||||
|
title: entry.title || 'Sans titre',
|
||||||
|
color: entry.project?.color ?? '#94a3b8',
|
||||||
|
durationMinutes,
|
||||||
|
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||||
|
offsetY: payload.offsetY,
|
||||||
|
targetDayIndex: sourceDayIndex,
|
||||||
|
ghostTopPx: (startMinutes / 60) * hourHeight,
|
||||||
|
snappedMinutes: startMinutes,
|
||||||
|
timeLabel: `${formatMinutes(startMinutes)} – ${formatMinutes(endMinutes)}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
document.addEventListener('mousemove', onDragMove)
|
||||||
|
document.addEventListener('mouseup', onDragEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDragPosition(event: MouseEvent) {
|
||||||
|
if (!dragState.value) return
|
||||||
|
|
||||||
|
// Find which column the cursor is over
|
||||||
|
let targetDayIndex = dragState.value.targetDayIndex
|
||||||
|
for (let i = 0; i < dayColumnEls.value.length; i++) {
|
||||||
|
const el = dayColumnEls.value[i]
|
||||||
|
if (!el) continue
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (event.clientX >= rect.left && event.clientX <= rect.right) {
|
||||||
|
targetDayIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Y position in the target column
|
||||||
|
const targetCol = dayColumnEls.value[targetDayIndex]
|
||||||
|
if (!targetCol) return
|
||||||
|
const colRect = targetCol.getBoundingClientRect()
|
||||||
|
const y = event.clientY - colRect.top - dragState.value.offsetY
|
||||||
|
const snappedMinutes = getSnappedMinutesFromY(y)
|
||||||
|
const endMinutes = snappedMinutes + dragState.value.durationMinutes
|
||||||
|
|
||||||
|
dragState.value.targetDayIndex = targetDayIndex
|
||||||
|
dragState.value.snappedMinutes = snappedMinutes
|
||||||
|
dragState.value.ghostTopPx = (snappedMinutes / 60) * hourHeight
|
||||||
|
dragState.value.timeLabel = `${formatMinutes(snappedMinutes)} – ${formatMinutes(endMinutes)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(event: MouseEvent) {
|
||||||
|
if (!dragState.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
lastMouseEvent = event
|
||||||
|
updateDragPosition(event)
|
||||||
|
|
||||||
|
// Start auto-scroll if not running
|
||||||
|
if (!autoScrollActive) {
|
||||||
|
autoScrollActive = true
|
||||||
|
requestAnimationFrame(autoScrollLoop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoScrollLoop() {
|
||||||
|
const scrollParent = getScrollParent()
|
||||||
|
if (!autoScrollActive || !lastMouseEvent || !scrollParent || !dragState.value) {
|
||||||
|
autoScrollActive = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = scrollParent.getBoundingClientRect()
|
||||||
|
const edgeSize = 60
|
||||||
|
const maxSpeed = 10
|
||||||
|
|
||||||
|
const distFromTop = lastMouseEvent.clientY - rect.top
|
||||||
|
const distFromBottom = rect.bottom - lastMouseEvent.clientY
|
||||||
|
|
||||||
|
let scrolled = false
|
||||||
|
if (distFromTop < edgeSize && distFromTop > 0) {
|
||||||
|
scrollParent.scrollTop -= maxSpeed * (1 - distFromTop / edgeSize)
|
||||||
|
scrolled = true
|
||||||
|
} else if (distFromBottom < edgeSize && distFromBottom > 0) {
|
||||||
|
scrollParent.scrollTop += maxSpeed * (1 - distFromBottom / edgeSize)
|
||||||
|
scrolled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ghost position if we scrolled (scroll changes coordinate mapping)
|
||||||
|
if (scrolled && lastMouseEvent) {
|
||||||
|
updateDragPosition(lastMouseEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(autoScrollLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
document.removeEventListener('mousemove', onDragMove)
|
||||||
|
document.removeEventListener('mouseup', onDragEnd)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
autoScrollActive = false
|
||||||
|
lastMouseEvent = null
|
||||||
|
|
||||||
|
if (!dragState.value) return
|
||||||
|
|
||||||
|
const state = dragState.value
|
||||||
|
const targetDay = days.value[state.targetDayIndex]
|
||||||
|
|
||||||
|
if (targetDay) {
|
||||||
|
const h = Math.floor(state.snappedMinutes / 60)
|
||||||
|
const m = state.snappedMinutes % 60
|
||||||
|
const newStart = new Date(targetDay.date)
|
||||||
|
newStart.setHours(h, m, 0, 0)
|
||||||
|
const newStop = new Date(newStart.getTime() + state.durationMinutes * 60000)
|
||||||
|
|
||||||
|
emit('moveEntry', state.entry, newStart.toISOString(), newStop.toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.value = null
|
||||||
|
dragEndTime = Date.now()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
68
frontend/components/ui/AppDrawer.vue
Normal file
68
frontend/components/ui/AppDrawer.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="drawer" appear>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 flex justify-end"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/30"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drawer-enter-active,
|
||||||
|
.drawer-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-active > div:last-child,
|
||||||
|
.drawer-leave-active > div:last-child {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-from,
|
||||||
|
.drawer-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-from > div:last-child,
|
||||||
|
.drawer-leave-to > div:last-child {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="group relative flex gap-4">
|
<div class="group relative flex gap-4">
|
||||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
||||||
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
31
frontend/components/ui/ColorPicker.vue
Normal file
31
frontend/components/ui/ColorPicker.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
type="button"
|
||||||
|
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
||||||
|
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="emit('update:modelValue', color)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#222783', '#26A69A', '#E91E63', '#4A90D9',
|
||||||
|
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
|
||||||
|
]
|
||||||
|
</script>
|
||||||
96
frontend/components/ui/ConfirmDeleteStatusModal.vue
Normal file
96
frontend/components/ui/ConfirmDeleteStatusModal.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
|
||||||
|
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
|
||||||
|
Choisissez où les déplacer :
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="targetStatusId"
|
||||||
|
:options="targetOptions"
|
||||||
|
label="Déplacer vers"
|
||||||
|
empty-option-label="Backlog (sans statut)"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-[red-600] px-4 py-2 text-sm font-semibold text-white hover:bg-[red-700] disabled:opacity-50"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
statusLabel: string
|
||||||
|
taskCount: number
|
||||||
|
availableStatuses: TaskStatus[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'confirm', targetStatusId: number | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const targetStatusId = ref<number | null>(null)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
|
||||||
|
const targetOptions = computed(() =>
|
||||||
|
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
targetStatusId.value = null
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
isProcessing.value = true
|
||||||
|
emit('confirm', targetStatusId.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
frontend/components/ui/ConfirmDeleteTaskModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteTaskModal.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('tasks.deleteConfirmTitle') }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ $t('tasks.deleteConfirmMessage') }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
frontend/components/ui/DataTable.vue
Normal file
81
frontend/components/ui/DataTable.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
class="px-4 py-3 font-semibold text-neutral-700"
|
||||||
|
>
|
||||||
|
{{ col.label }}
|
||||||
|
</th>
|
||||||
|
<th v-if="deletable || $slots.actions" class="px-4 py-3 font-semibold text-neutral-700">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
|
||||||
|
@click="$emit('row-click', item)"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
class="px-4 py-3"
|
||||||
|
:class="[col.class, { 'font-semibold text-primary-500': col.primary }]"
|
||||||
|
>
|
||||||
|
<slot :name="`cell-${col.key}`" :item="item" :value="item[col.key]">
|
||||||
|
{{ item[col.key] }}
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<slot name="actions" :item="item" />
|
||||||
|
<button
|
||||||
|
v-if="deletable"
|
||||||
|
class="text-[red-500] hover:text-[red-700]"
|
||||||
|
@click.stop="$emit('delete', item)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="items.length === 0 && !loading">
|
||||||
|
<td
|
||||||
|
:colspan="columns.length + (deletable || $slots.actions ? 1 : 0)"
|
||||||
|
class="px-4 py-8 text-center text-neutral-400"
|
||||||
|
>
|
||||||
|
{{ emptyMessage }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface DataTableColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
primary?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
columns: DataTableColumn[]
|
||||||
|
items: Record<string, any>[]
|
||||||
|
loading?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
deletable?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'row-click', item: any): void
|
||||||
|
(e: 'delete', item: any): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
52
frontend/components/ui/SidebarLink.vue
Normal file
52
frontend/components/ui/SidebarLink.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
||||||
|
:class="linkClasses"
|
||||||
|
:active-class="exact ? '' : activeClass"
|
||||||
|
:exact-active-class="exact ? activeClass : ''"
|
||||||
|
>
|
||||||
|
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
v-if="!collapsed"
|
||||||
|
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
||||||
|
:class="sub ? 'text-sm' : 'text-md'"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="collapsed"
|
||||||
|
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
to: string
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
collapsed: boolean
|
||||||
|
sub?: boolean
|
||||||
|
exact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeClass = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return '!text-primary-500 bg-primary-500/10'
|
||||||
|
}
|
||||||
|
return '!text-primary-500 bg-tertiary-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkClasses = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
||||||
|
}
|
||||||
|
if (props.sub) {
|
||||||
|
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
||||||
|
}
|
||||||
|
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
26
frontend/components/ui/SidebarTimer.vue
Normal file
26
frontend/components/ui/SidebarTimer.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white transition"
|
||||||
|
:class="timerStore.isRunning
|
||||||
|
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||||
|
: 'bg-primary-500 hover:bg-primary-600'"
|
||||||
|
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||||
|
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
|
||||||
|
size="16"
|
||||||
|
/>
|
||||||
|
<span v-if="!collapsed" class="font-mono tracking-wide">
|
||||||
|
{{ timerStore.elapsedFormatted }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
collapsed: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const timerStore = useTimerStore()
|
||||||
|
</script>
|
||||||
133
frontend/components/user/UserDrawer.vue
Normal file
133
frontend/components/user/UserDrawer.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
|
||||||
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.username"
|
||||||
|
label="Nom d'utilisateur"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||||
|
@blur="touched.username = true"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.password"
|
||||||
|
label="Mot de passe"
|
||||||
|
input-class="w-full"
|
||||||
|
type="password"
|
||||||
|
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||||
|
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||||
|
@blur="touched.password = true"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="text-sm font-semibold text-neutral-700">Rôles</label>
|
||||||
|
<div class="mt-2 flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="role in availableRoles"
|
||||||
|
:key="role"
|
||||||
|
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.roles"
|
||||||
|
type="checkbox"
|
||||||
|
:value="role"
|
||||||
|
class="rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
{{ role }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: UserData | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
roles: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
username: false,
|
||||||
|
password: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.username = props.item.username ?? ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = [...props.item.roles]
|
||||||
|
} else {
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = ['ROLE_USER']
|
||||||
|
}
|
||||||
|
touched.username = false
|
||||||
|
touched.password = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useUserService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.username = true
|
||||||
|
touched.password = true
|
||||||
|
if (!form.username.trim()) return
|
||||||
|
if (!isEditing.value && !form.password) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: UserWrite = {
|
||||||
|
username: form.username.trim(),
|
||||||
|
roles: form.roles,
|
||||||
|
}
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -31,7 +31,7 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
|||||||
|
|
||||||
export const useApi = (): ApiClient => {
|
export const useApi = (): ApiClient => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const baseURL = config.public.apiBase ?? '/api'
|
const baseURL = config.public.apiBase || '/api'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
|
|||||||
@@ -18,5 +18,71 @@
|
|||||||
"login": "Connexion réussie.",
|
"login": "Connexion réussie.",
|
||||||
"logout": "Déconnexion réussie."
|
"logout": "Déconnexion réussie."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"created": "Client créé avec succès.",
|
||||||
|
"updated": "Client mis à jour avec succès.",
|
||||||
|
"deleted": "Client supprimé avec succès."
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"created": "Projet créé avec succès.",
|
||||||
|
"updated": "Projet mis à jour avec succès.",
|
||||||
|
"deleted": "Projet supprimé avec succès."
|
||||||
|
},
|
||||||
|
"taskStatuses": {
|
||||||
|
"created": "Statut créé avec succès.",
|
||||||
|
"updated": "Statut mis à jour avec succès.",
|
||||||
|
"deleted": "Statut supprimé avec succès."
|
||||||
|
},
|
||||||
|
"taskEfforts": {
|
||||||
|
"created": "Effort créé avec succès.",
|
||||||
|
"updated": "Effort mis à jour avec succès.",
|
||||||
|
"deleted": "Effort supprimé avec succès."
|
||||||
|
},
|
||||||
|
"taskPriorities": {
|
||||||
|
"created": "Priorité créée avec succès.",
|
||||||
|
"updated": "Priorité mise à jour avec succès.",
|
||||||
|
"deleted": "Priorité supprimée avec succès."
|
||||||
|
},
|
||||||
|
"taskTags": {
|
||||||
|
"created": "Tag créé avec succès.",
|
||||||
|
"updated": "Tag mis à jour avec succès.",
|
||||||
|
"deleted": "Tag supprimé avec succès."
|
||||||
|
},
|
||||||
|
"taskGroups": {
|
||||||
|
"created": "Groupe créé avec succès.",
|
||||||
|
"updated": "Groupe mis à jour avec succès.",
|
||||||
|
"deleted": "Groupe supprimé avec succès.",
|
||||||
|
"archived": "Groupe archivé avec succès.",
|
||||||
|
"unarchived": "Groupe désarchivé avec succès."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"created": "Ticket créé avec succès.",
|
||||||
|
"updated": "Ticket mis à jour avec succès.",
|
||||||
|
"deleted": "Ticket supprimé avec succès.",
|
||||||
|
"archived": "Ticket archivé avec succès.",
|
||||||
|
"unarchived": "Ticket désarchivé avec succès.",
|
||||||
|
"deleteConfirmTitle": "Supprimer le ticket",
|
||||||
|
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"created": "Utilisateur créé avec succès.",
|
||||||
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
|
"deleted": "Utilisateur supprimé avec succès."
|
||||||
|
},
|
||||||
|
"timeEntries": {
|
||||||
|
"created": "Temps enregistré",
|
||||||
|
"updated": "Temps modifié",
|
||||||
|
"deleted": "Temps supprimé"
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"title": "Archives",
|
||||||
|
"empty": "Aucun ticket archivé.",
|
||||||
|
"archiveButton": "Archiver",
|
||||||
|
"unarchiveButton": "Désarchiver",
|
||||||
|
"showArchived": "Voir les groupes archivés",
|
||||||
|
"hideArchived": "Masquer les groupes archivés",
|
||||||
|
"statusFinal": "Statut final",
|
||||||
|
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,204 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
<aside
|
||||||
<div>
|
class="flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-all duration-300"
|
||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
:class="ui.sidebarCollapsed ? 'w-16' : 'w-64'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center overflow-hidden" :class="ui.sidebarCollapsed ? 'p-2' : ''">
|
||||||
|
<img
|
||||||
|
v-if="!ui.sidebarCollapsed"
|
||||||
|
src="/malio.png"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-auto"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="/malio.png"
|
||||||
|
alt="Logo"
|
||||||
|
class="h-8 w-8 object-cover object-left"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 overflow-hidden" :class="ui.sidebarCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||||
<NuxtLink
|
<SidebarLink
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
icon="mdi:question-mark"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
label="Tableau de bord"
|
||||||
>
|
:collapsed="ui.sidebarCollapsed"
|
||||||
<Icon name="mdi:question-mark" size="24"/>
|
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||||
<span class="self-baseline text-md">Tableau de bord</span>
|
/>
|
||||||
</NuxtLink>
|
<SidebarLink
|
||||||
<NuxtLink
|
to="/projects"
|
||||||
to="/project-list"
|
icon="mdi:folder-outline"
|
||||||
class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
|
label="Projets"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:collapsed="ui.sidebarCollapsed"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:folder-outline" size="24"/>
|
<template v-if="currentProjectId">
|
||||||
<span class="self-baseline text-md">Projets</span>
|
<SidebarLink
|
||||||
</NuxtLink>
|
:to="`/projects/${currentProjectId}`"
|
||||||
|
icon="mdi:view-column-outline"
|
||||||
|
label="Kanban"
|
||||||
|
:collapsed="ui.sidebarCollapsed"
|
||||||
|
sub
|
||||||
|
exact
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
:to="`/projects/${currentProjectId}/groups`"
|
||||||
|
icon="mdi:tag-multiple-outline"
|
||||||
|
label="Groupes"
|
||||||
|
:collapsed="ui.sidebarCollapsed"
|
||||||
|
sub
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
:to="`/projects/${currentProjectId}/archives`"
|
||||||
|
icon="mdi:archive-outline"
|
||||||
|
label="Archives"
|
||||||
|
:collapsed="ui.sidebarCollapsed"
|
||||||
|
sub
|
||||||
|
/>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<SidebarLink
|
||||||
|
to="/time-tracking"
|
||||||
|
icon="mdi:clock-outline"
|
||||||
|
label="Suivi de temps"
|
||||||
|
:collapsed="ui.sidebarCollapsed"
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
to="/admin"
|
||||||
|
icon="mdi:cog-outline"
|
||||||
|
label="Administration"
|
||||||
|
:collapsed="ui.sidebarCollapsed"
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 items-center p-4">
|
<div class="flex flex-col gap-2 items-center p-4">
|
||||||
<p class="font-bold">v 0.0.0</p>
|
<p v-if="!ui.sidebarCollapsed" class="font-bold">v {{ version }}</p>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors"
|
||||||
|
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||||
|
@click="ui.toggleSidebar()"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||||
<AppTopNav :user="auth.user" />
|
<AppTopNav :user="auth.user" />
|
||||||
<main class="flex-1 overflow-y-auto px-8 py-12">
|
<main class="flex-1 overflow-y-auto bg-white px-16 pb-24">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 bg-white" />
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TimeEntryDrawer
|
||||||
|
v-model="completeDrawerOpen"
|
||||||
|
:entry="timerStore.pendingCompleteEntry"
|
||||||
|
:users="refData.users"
|
||||||
|
:projects="refData.projects"
|
||||||
|
:tags="refData.tags"
|
||||||
|
@saved="onCompleteSaved"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useAppVersion} from "~/composables/useAppVersion";
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import { useAppVersion } from '~/composables/useAppVersion'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const ui = useUiStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const currentProjectId = computed(() => {
|
||||||
|
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
|
const baseTitle = ref('Lesstime')
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: (title) => {
|
||||||
|
baseTitle.value = title || 'Lesstime'
|
||||||
|
return title || 'Lesstime'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => timerStore.elapsedFormatted, () => timerStore.isRunning, () => timerStore.activeEntry?.title],
|
||||||
|
([elapsed, running, label]) => {
|
||||||
|
if (import.meta.server) return
|
||||||
|
const base = baseTitle.value
|
||||||
|
if (running) {
|
||||||
|
document.title = label ? `${base} | ${elapsed} · ${label}` : `${base} | ${elapsed}`
|
||||||
|
} else {
|
||||||
|
document.title = base
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timerStore.fetchActive()
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeDrawerOpen = ref(false)
|
||||||
|
const refData = reactive({
|
||||||
|
users: [] as UserData[],
|
||||||
|
projects: [] as Project[],
|
||||||
|
tags: [] as TaskTag[],
|
||||||
|
loaded: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadRefData() {
|
||||||
|
if (refData.loaded) return
|
||||||
|
const api = useApi()
|
||||||
|
const [usersData, projectsData, typesData] = await Promise.all([
|
||||||
|
api.get<any>('/users'),
|
||||||
|
api.get<any>('/projects'),
|
||||||
|
api.get<any>('/task_tags'),
|
||||||
|
])
|
||||||
|
refData.users = extractHydraMembers(usersData)
|
||||||
|
refData.projects = extractHydraMembers(projectsData)
|
||||||
|
refData.tags = extractHydraMembers(typesData)
|
||||||
|
refData.loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => timerStore.pendingCompleteEntry, async (entry) => {
|
||||||
|
if (entry) {
|
||||||
|
await loadRefData()
|
||||||
|
completeDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(completeDrawerOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
nextTick(() => {
|
||||||
|
timerStore.clearPendingEntry()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onCompleteSaved() {
|
||||||
|
completeDrawerOpen.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
timerStore.clearPendingEntry()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
|
|||||||
@@ -20,7 +20,31 @@ export default defineNuxtConfig({
|
|||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
devServer: {port: 3002},
|
devServer: {
|
||||||
|
port: 3002,
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://nginx',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{path: '~/components', pathPrefix: false},
|
||||||
|
],
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://nginx',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
timeout: 2000,
|
timeout: 2000,
|
||||||
|
|||||||
62
frontend/nuxt.config.ts.new
Normal file
62
frontend/nuxt.config.ts.new
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: {enabled: false},
|
||||||
|
ssr: false,
|
||||||
|
app: {
|
||||||
|
baseURL: process.env.NODE_ENV === 'production'
|
||||||
|
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||||
|
: '/'
|
||||||
|
},
|
||||||
|
extends: ['@malio/layer-ui'],
|
||||||
|
modules: [
|
||||||
|
'@nuxtjs/tailwindcss',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
'nuxt-toast',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/icon',
|
||||||
|
],
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
port: 3002,
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://nginx',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://nginx',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
settings: {
|
||||||
|
timeout: 2000,
|
||||||
|
closeOnClick: true,
|
||||||
|
progressBar: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
strategy: 'no_prefix',
|
||||||
|
defaultLocale: 'fr',
|
||||||
|
langDir: 'locales',
|
||||||
|
locales: [
|
||||||
|
{code: 'fr', file: 'fr.json', name: 'Français'}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
strict: true
|
||||||
|
}
|
||||||
|
})
|
||||||
92
frontend/package-lock.json
generated
92
frontend/package-lock.json
generated
@@ -72,7 +72,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1028,6 +1027,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1037,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/object-schema": "^3.0.3",
|
"@eslint/object-schema": "^3.0.3",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
@@ -1051,6 +1052,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^1.1.1"
|
"@eslint/core": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -1063,6 +1065,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/json-schema": "^7.0.15"
|
"@types/json-schema": "^7.0.15"
|
||||||
},
|
},
|
||||||
@@ -1075,6 +1078,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
@@ -1084,6 +1088,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^1.1.1",
|
"@eslint/core": "^1.1.1",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
@@ -1097,6 +1102,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.18.0"
|
"node": ">=18.18.0"
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1112,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@humanfs/core": "^0.19.1",
|
"@humanfs/core": "^0.19.1",
|
||||||
"@humanwhocodes/retry": "^0.4.0"
|
"@humanwhocodes/retry": "^0.4.0"
|
||||||
@@ -1119,6 +1126,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22"
|
"node": ">=12.22"
|
||||||
},
|
},
|
||||||
@@ -1132,6 +1140,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.18"
|
"node": ">=18.18"
|
||||||
},
|
},
|
||||||
@@ -2405,7 +2414,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
||||||
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "^3.3.3",
|
"c12": "^3.3.3",
|
||||||
"consola": "^3.4.2",
|
"consola": "^3.4.2",
|
||||||
@@ -2478,7 +2486,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
||||||
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "^3.5.27",
|
"@vue/shared": "^3.5.27",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -3125,7 +3132,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
||||||
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.95.0"
|
"@oxc-project/types": "^0.95.0"
|
||||||
},
|
},
|
||||||
@@ -5231,7 +5237,8 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
@@ -5243,7 +5250,8 @@
|
|||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
@@ -5578,7 +5586,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.0",
|
||||||
"@vue/compiler-core": "3.5.29",
|
"@vue/compiler-core": "3.5.29",
|
||||||
@@ -5772,7 +5779,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5812,6 +5818,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -6143,7 +6150,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"bare-abort-controller": "*"
|
"bare-abort-controller": "*"
|
||||||
},
|
},
|
||||||
@@ -6337,7 +6343,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6466,7 +6471,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -6632,7 +6636,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
}
|
}
|
||||||
@@ -7166,7 +7169,8 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
@@ -7666,6 +7670,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/esrecurse": "^4.3.1",
|
"@types/esrecurse": "^4.3.1",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
@@ -7696,6 +7701,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -7708,6 +7714,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
@@ -7720,6 +7727,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.3"
|
"is-glob": "^4.0.3"
|
||||||
},
|
},
|
||||||
@@ -7732,6 +7740,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
@@ -7741,6 +7750,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.16.0",
|
||||||
"acorn-jsx": "^5.3.2",
|
"acorn-jsx": "^5.3.2",
|
||||||
@@ -7758,6 +7768,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
@@ -7783,6 +7794,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"estraverse": "^5.1.0"
|
"estraverse": "^5.1.0"
|
||||||
},
|
},
|
||||||
@@ -7795,6 +7807,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"estraverse": "^5.2.0"
|
"estraverse": "^5.2.0"
|
||||||
},
|
},
|
||||||
@@ -7895,7 +7908,8 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
@@ -7923,13 +7937,15 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fast-npm-meta": {
|
"node_modules/fast-npm-meta": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
@@ -7974,6 +7990,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flat-cache": "^4.0.0"
|
"flat-cache": "^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -8004,6 +8021,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^6.0.0",
|
"locate-path": "^6.0.0",
|
||||||
"path-exists": "^4.0.0"
|
"path-exists": "^4.0.0"
|
||||||
@@ -8020,6 +8038,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flatted": "^3.2.9",
|
"flatted": "^3.2.9",
|
||||||
"keyv": "^4.5.4"
|
"keyv": "^4.5.4"
|
||||||
@@ -8032,7 +8051,8 @@
|
|||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
@@ -8565,6 +8585,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
@@ -8941,19 +8962,22 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
@@ -9031,6 +9055,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
@@ -9291,6 +9316,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prelude-ls": "^1.2.1",
|
"prelude-ls": "^1.2.1",
|
||||||
"type-check": "~0.4.0"
|
"type-check": "~0.4.0"
|
||||||
@@ -9375,6 +9401,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^5.0.0"
|
"p-locate": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -9766,7 +9793,8 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
@@ -10002,7 +10030,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
||||||
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dxup/nuxt": "^0.3.2",
|
"@dxup/nuxt": "^0.3.2",
|
||||||
"@nuxt/cli": "^3.33.0",
|
"@nuxt/cli": "^3.33.0",
|
||||||
@@ -10273,6 +10300,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
@@ -10324,7 +10352,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.112.0"
|
"@oxc-project/types": "^0.112.0"
|
||||||
},
|
},
|
||||||
@@ -10408,6 +10435,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yocto-queue": "^0.1.0"
|
"yocto-queue": "^0.1.0"
|
||||||
},
|
},
|
||||||
@@ -10423,6 +10451,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^3.0.2"
|
"p-limit": "^3.0.2"
|
||||||
},
|
},
|
||||||
@@ -10465,6 +10494,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -10568,7 +10598,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^7.7.7"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
@@ -10685,7 +10714,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -11229,7 +11257,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -11280,6 +11307,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
@@ -11316,6 +11344,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -11677,7 +11706,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -12460,7 +12488,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -12801,6 +12828,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prelude-ls": "^1.2.1"
|
"prelude-ls": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -12868,7 +12896,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -13304,6 +13331,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -13328,7 +13356,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -13690,7 +13717,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.29",
|
"@vue/compiler-dom": "3.5.29",
|
||||||
"@vue/compiler-sfc": "3.5.29",
|
"@vue/compiler-sfc": "3.5.29",
|
||||||
@@ -13727,7 +13753,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.3.0",
|
"@intlify/core-base": "11.3.0",
|
||||||
"@intlify/devtools-types": "11.3.0",
|
"@intlify/devtools-types": "11.3.0",
|
||||||
@@ -13749,7 +13774,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
@@ -13802,6 +13826,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -13970,6 +13995,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|||||||
47
frontend/pages/admin.vue
Normal file
47
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">Administration</h1>
|
||||||
|
|
||||||
|
<div class="mt-6 border-b border-neutral-200">
|
||||||
|
<nav class="flex gap-6">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="px-1 pb-3 text-sm font-semibold transition"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-b-2 border-primary-500 text-primary-500'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||||
|
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||||
|
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||||
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Administration' })
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'clients', label: 'Clients' },
|
||||||
|
{ key: 'statuses', label: 'Statuts' },
|
||||||
|
{ key: 'efforts', label: 'Efforts' },
|
||||||
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
|
{ key: 'tags', label: 'Tags' },
|
||||||
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabKey = typeof tabs[number]['key']
|
||||||
|
|
||||||
|
const activeTab = ref<TabKey>('clients')
|
||||||
|
</script>
|
||||||
161
frontend/pages/projects/[id]/archives.vue
Normal file
161
frontend/pages/projects/[id]/archives.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedGroupId"
|
||||||
|
:options="groupFilterOptions"
|
||||||
|
label="Groupe"
|
||||||
|
empty-option-label="Tous les groupes"
|
||||||
|
min-width="w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||||
|
{{ $t('archive.empty') }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="task in filteredTasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
|
||||||
|
@click="openTaskEdit(task)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-bold text-neutral-400">{{ project?.code }}-{{ task.number }}</span>
|
||||||
|
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="task.status"
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: task.status.color }"
|
||||||
|
>
|
||||||
|
{{ task.status.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="task.group"
|
||||||
|
class="rounded-full border px-2 py-0.5 text-xs font-semibold"
|
||||||
|
:style="{ borderColor: task.group.color, color: task.group.color }"
|
||||||
|
>
|
||||||
|
{{ task.group.title }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="task.assignee"
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||||
|
:title="task.assignee.username"
|
||||||
|
>
|
||||||
|
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskDrawer
|
||||||
|
v-model="taskDrawerOpen"
|
||||||
|
:task="selectedTask"
|
||||||
|
:project-id="projectId"
|
||||||
|
:statuses="statuses"
|
||||||
|
:efforts="efforts"
|
||||||
|
:priorities="priorities"
|
||||||
|
:tags="tags"
|
||||||
|
:groups="groups"
|
||||||
|
:users="users"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { Task } 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 { useProjectService } from '~/services/projects'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const projectId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
useHead({ title: 'Archives' })
|
||||||
|
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
const statusService = useTaskStatusService()
|
||||||
|
const effortService = useTaskEffortService()
|
||||||
|
const priorityService = useTaskPriorityService()
|
||||||
|
const tagService = useTaskTagService()
|
||||||
|
const groupService = useTaskGroupService()
|
||||||
|
const userService = useUserService()
|
||||||
|
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
const archivedTasks = ref<Task[]>([])
|
||||||
|
const statuses = ref<TaskStatus[]>([])
|
||||||
|
const efforts = ref<TaskEffort[]>([])
|
||||||
|
const priorities = ref<TaskPriority[]>([])
|
||||||
|
const tags = ref<TaskTag[]>([])
|
||||||
|
const groups = ref<TaskGroup[]>([])
|
||||||
|
const users = ref<UserData[]>([])
|
||||||
|
|
||||||
|
const selectedGroupId = ref<number | null>(null)
|
||||||
|
const taskDrawerOpen = ref(false)
|
||||||
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
|
||||||
|
const groupFilterOptions = computed(() =>
|
||||||
|
groups.value.map(g => ({ label: g.title, value: g.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
if (!selectedGroupId.value) return archivedTasks.value
|
||||||
|
return archivedTasks.value.filter(t => t.group?.id === selectedGroupId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||||
|
projectService.getById(projectId.value),
|
||||||
|
taskService.getByProjectArchived(projectId.value),
|
||||||
|
statusService.getAll(),
|
||||||
|
effortService.getAll(),
|
||||||
|
priorityService.getAll(),
|
||||||
|
tagService.getAll(),
|
||||||
|
groupService.getByProject(projectId.value),
|
||||||
|
userService.getAll(),
|
||||||
|
])
|
||||||
|
project.value = p
|
||||||
|
archivedTasks.value = t
|
||||||
|
statuses.value = s
|
||||||
|
efforts.value = e
|
||||||
|
priorities.value = pr
|
||||||
|
tags.value = ty
|
||||||
|
groups.value = g
|
||||||
|
users.value = u
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTaskEdit(task: Task) {
|
||||||
|
selectedTask.value = task
|
||||||
|
taskDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
32
frontend/pages/projects/[id]/groups.vue
Normal file
32
frontend/pages/projects/[id]/groups.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} — Groupes</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<ProjectGroupTab :project-id="projectId" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const projectId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
useHead({ title: 'Groupes du projet' })
|
||||||
|
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
|
||||||
|
async function loadProject() {
|
||||||
|
project.value = await projectService.getById(projectId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProject()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
290
frontend/pages/projects/[id]/index.vue
Normal file
290
frontend/pages/projects/[id]/index.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }}</h1>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openTaskCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedGroupId"
|
||||||
|
:options="groupFilterOptions"
|
||||||
|
label="Groupe"
|
||||||
|
empty-option-label="Tous les groupes"
|
||||||
|
min-width="w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kanban -->
|
||||||
|
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||||
|
<div
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||||
|
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="onDragEnter(status.id)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDropStatus($event, status)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||||
|
:style="{ backgroundColor: status.color }"
|
||||||
|
>
|
||||||
|
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3 p-3">
|
||||||
|
<TaskCard
|
||||||
|
v-for="task in tasksByStatus(status.id)"
|
||||||
|
:key="task.id"
|
||||||
|
:task="task"
|
||||||
|
@click="openTaskEdit(task)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="tasksByStatus(status.id).length === 0"
|
||||||
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
|
>
|
||||||
|
Aucun ticket
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backlog -->
|
||||||
|
<div
|
||||||
|
class="mt-8 rounded-lg p-4 transition-colors"
|
||||||
|
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="onDragEnter(0)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDropBacklog($event)"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Backlog</h2>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="task in backlogTasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onBacklogDragStart($event, task)"
|
||||||
|
@dragend="onBacklogDragEnd"
|
||||||
|
@click="openTaskEdit(task)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
<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-if="task.effort"
|
||||||
|
class="text-sm font-bold text-neutral-700"
|
||||||
|
>
|
||||||
|
{{ task.effort.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="task.assignee"
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||||
|
:title="task.assignee.username"
|
||||||
|
>
|
||||||
|
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:account-outline" size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskDrawer
|
||||||
|
v-model="taskDrawerOpen"
|
||||||
|
:task="selectedTask"
|
||||||
|
:project-id="projectId"
|
||||||
|
:statuses="statuses"
|
||||||
|
:efforts="efforts"
|
||||||
|
:priorities="priorities"
|
||||||
|
:tags="tags"
|
||||||
|
:groups="groups"
|
||||||
|
:users="users"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { Task } 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 { useProjectService } from '~/services/projects'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const projectId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
useHead({ title: 'Projet' })
|
||||||
|
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
const statusService = useTaskStatusService()
|
||||||
|
const effortService = useTaskEffortService()
|
||||||
|
const priorityService = useTaskPriorityService()
|
||||||
|
const tagService = useTaskTagService()
|
||||||
|
const groupService = useTaskGroupService()
|
||||||
|
const userService = useUserService()
|
||||||
|
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const statuses = ref<TaskStatus[]>([])
|
||||||
|
const efforts = ref<TaskEffort[]>([])
|
||||||
|
const priorities = ref<TaskPriority[]>([])
|
||||||
|
const tags = ref<TaskTag[]>([])
|
||||||
|
const groups = ref<TaskGroup[]>([])
|
||||||
|
const users = ref<UserData[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const selectedGroupId = ref<number | null>(null)
|
||||||
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
|
const dragCounter = ref(0)
|
||||||
|
const taskDrawerOpen = ref(false)
|
||||||
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
|
||||||
|
const groupFilterOptions = computed(() =>
|
||||||
|
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
let result = tasks.value.filter(t => !t.archived)
|
||||||
|
if (selectedGroupId.value) {
|
||||||
|
result = result.filter(t => t.group?.id === selectedGroupId.value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function tasksByStatus(statusId: number): Task[] {
|
||||||
|
return filteredTasks.value.filter(t => t.status?.id === statusId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backlogTasks = computed(() =>
|
||||||
|
filteredTasks.value.filter(t => !t.status)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||||
|
projectService.getById(projectId.value),
|
||||||
|
taskService.getByProject(projectId.value),
|
||||||
|
statusService.getAll(),
|
||||||
|
effortService.getAll(),
|
||||||
|
priorityService.getAll(),
|
||||||
|
tagService.getAll(),
|
||||||
|
groupService.getByProject(projectId.value),
|
||||||
|
userService.getAll(),
|
||||||
|
])
|
||||||
|
project.value = p
|
||||||
|
tasks.value = t
|
||||||
|
statuses.value = s
|
||||||
|
efforts.value = e
|
||||||
|
priorities.value = pr
|
||||||
|
tags.value = ty
|
||||||
|
groups.value = g
|
||||||
|
users.value = u
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTaskCreate() {
|
||||||
|
selectedTask.value = null
|
||||||
|
taskDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTaskEdit(task: Task) {
|
||||||
|
selectedTask.value = task
|
||||||
|
taskDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(id: number) {
|
||||||
|
dragCounter.value++
|
||||||
|
dragOverStatusId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragCounter.value--
|
||||||
|
if (dragCounter.value === 0) {
|
||||||
|
dragOverStatusId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
dragCounter.value = 0
|
||||||
|
dragOverStatusId.value = null
|
||||||
|
return Number(event.dataTransfer!.getData('text/plain'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBacklogDragStart(event: DragEvent, task: Task) {
|
||||||
|
event.dataTransfer!.effectAllowed = 'move'
|
||||||
|
event.dataTransfer!.setData('text/plain', String(task.id))
|
||||||
|
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBacklogDragEnd(event: DragEvent) {
|
||||||
|
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||||
|
const taskId = onDrop(event)
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (!task || task.status?.id === status.id) return
|
||||||
|
task.status = status
|
||||||
|
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDropBacklog(event: DragEvent) {
|
||||||
|
const taskId = onDrop(event)
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (!task || !task.status) return
|
||||||
|
task.status = null
|
||||||
|
await taskService.update(taskId, { status: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
101
frontend/pages/projects/index.vue
Normal file
101
frontend/pages/projects/index.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">Projets</h1>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un projet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
|
||||||
|
@click="navigateTo(`/projects/${project.id}`)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-md font-bold" :style="{ color: project.color }">{{ project.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 text-neutral-400 hover:text-primary-500"
|
||||||
|
@click.stop="openEdit(project)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:pencil-outline" size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
||||||
|
{{ project.description ?? '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="projects.length === 0 && !isLoading"
|
||||||
|
class="col-span-full py-12 text-center text-neutral-400"
|
||||||
|
>
|
||||||
|
Aucun projet trouvé.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:project="selectedProject"
|
||||||
|
:clients="clients"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useClientService } from '~/services/clients'
|
||||||
|
|
||||||
|
useHead({ title: 'Projets' })
|
||||||
|
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const clientService = useClientService()
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedProject = ref<Project | null>(null)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const [p, c] = await Promise.all([
|
||||||
|
projectService.getAll(),
|
||||||
|
clientService.getAll(),
|
||||||
|
])
|
||||||
|
projects.value = p
|
||||||
|
clients.value = c
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedProject.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(project: Project) {
|
||||||
|
selectedProject.value = project
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
338
frontend/pages/time-tracking.vue
Normal file
338
frontend/pages/time-tracking.vue
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div ref="pageHeaderEl" class="sticky top-0 z-40 bg-white pb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500">Suivi des temps</h1>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||||
|
@click="openCreateDrawer()"
|
||||||
|
>
|
||||||
|
+ Ajouter une Activité
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-30 mt-4 flex items-center gap-4">
|
||||||
|
<h2 class="text-lg font-bold text-orange-500">
|
||||||
|
{{ currentMonthLabel }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
|
||||||
|
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||||
|
<Icon name="mdi:chevron-left" size="20" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||||
|
:key="mode"
|
||||||
|
class="px-3 py-1 text-sm font-semibold transition"
|
||||||
|
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||||
|
@click="viewMode = mode"
|
||||||
|
>
|
||||||
|
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||||
|
</button>
|
||||||
|
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||||
|
<Icon name="mdi:chevron-right" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
min-width="!w-40"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
label="User"
|
||||||
|
empty-option-label="User"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
empty-option-label="Tous"
|
||||||
|
label="Projet"
|
||||||
|
min-width="!w-40"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedTagId"
|
||||||
|
:options="tagOptions"
|
||||||
|
empty-option-label="Tous"
|
||||||
|
label="Tag"
|
||||||
|
min-width="!w-40"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<TimeEntryList
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
|
:entries="filteredEntries"
|
||||||
|
@edit-entry="openEditDrawer"
|
||||||
|
@delete-entry="onDelete"
|
||||||
|
/>
|
||||||
|
<TimeTrackingCalendar
|
||||||
|
v-else
|
||||||
|
:entries="filteredEntries"
|
||||||
|
:start-date="startDate"
|
||||||
|
:view-mode="viewMode"
|
||||||
|
:sticky-offset="pageHeaderHeight"
|
||||||
|
@edit-entry="openEditDrawer"
|
||||||
|
@create-entry="openCreateDrawer"
|
||||||
|
@move-entry="onMoveEntry"
|
||||||
|
@resize-entry="onResizeEntry"
|
||||||
|
@contextmenu="onContextMenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimeEntryDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:entry="editingEntry"
|
||||||
|
:prefill-started-at="prefillStartedAt"
|
||||||
|
:users="users"
|
||||||
|
:projects="projects"
|
||||||
|
:tags="tags"
|
||||||
|
@saved="loadEntries"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimeEntryContextMenu
|
||||||
|
:visible="contextMenu.visible"
|
||||||
|
:x="contextMenu.x"
|
||||||
|
:y="contextMenu.y"
|
||||||
|
:entry="contextMenu.entry"
|
||||||
|
:can-paste="!!clipboard"
|
||||||
|
@close="contextMenu.visible = false"
|
||||||
|
@copy="onCopy"
|
||||||
|
@paste="onPaste"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
useHead({ title: 'Suivi des temps' })
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const timeEntryService = useTimeEntryService()
|
||||||
|
|
||||||
|
const viewMode = ref<'week' | 'day' | 'list'>('week')
|
||||||
|
const startDate = ref(getMonday(new Date()))
|
||||||
|
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||||
|
const selectedTagId = ref<number | null>(null)
|
||||||
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const entries = ref<TimeEntry[]>([])
|
||||||
|
const users = ref<UserData[]>([])
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const tags = ref<TaskTag[]>([])
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const editingEntry = ref<TimeEntry | null>(null)
|
||||||
|
const prefillStartedAt = ref<string | null>(null)
|
||||||
|
const clipboard = ref<TimeEntry | null>(null)
|
||||||
|
const pageHeaderEl = ref<HTMLElement | null>(null)
|
||||||
|
const pageHeaderHeight = ref(0)
|
||||||
|
|
||||||
|
const contextMenu = reactive({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
entry: null as TimeEntry | null,
|
||||||
|
targetDate: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentMonthLabel = computed(() => {
|
||||||
|
const d = startDate.value
|
||||||
|
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||||
|
return `${months[d.getMonth()]} ${d.getFullYear()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagOptions = computed(() =>
|
||||||
|
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
let pageHeaderResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function updatePageHeaderHeight() {
|
||||||
|
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = computed(() => {
|
||||||
|
let result = entries.value
|
||||||
|
if (selectedProjectId.value) {
|
||||||
|
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||||
|
}
|
||||||
|
if (selectedTagId.value) {
|
||||||
|
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function getMonday(d: Date): Date {
|
||||||
|
const date = new Date(d)
|
||||||
|
const day = date.getDay()
|
||||||
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||||
|
date.setDate(diff)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigatePrev() {
|
||||||
|
const d = new Date(startDate.value)
|
||||||
|
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
||||||
|
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateNext() {
|
||||||
|
const d = new Date(startDate.value)
|
||||||
|
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||||
|
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer(startedAt?: string) {
|
||||||
|
editingEntry.value = null
|
||||||
|
prefillStartedAt.value = startedAt ?? null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer(entry: TimeEntry) {
|
||||||
|
editingEntry.value = entry
|
||||||
|
prefillStartedAt.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||||
|
// Optimistic update — instant visual feedback
|
||||||
|
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const original = entries.value[idx]!
|
||||||
|
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||||
|
} catch {
|
||||||
|
entries.value[idx] = original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResizeEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||||
|
// Optimistic update — instant visual feedback
|
||||||
|
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const original = entries.value[idx]!
|
||||||
|
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||||
|
} catch {
|
||||||
|
entries.value[idx] = original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
||||||
|
contextMenu.visible = true
|
||||||
|
contextMenu.x = event.clientX
|
||||||
|
contextMenu.y = event.clientY
|
||||||
|
contextMenu.entry = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCopy(entry: TimeEntry) {
|
||||||
|
clipboard.value = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaste() {
|
||||||
|
if (!clipboard.value) return
|
||||||
|
const { create } = useTimeEntryService()
|
||||||
|
await create({
|
||||||
|
title: clipboard.value.title ?? undefined,
|
||||||
|
description: clipboard.value.description ?? undefined,
|
||||||
|
startedAt: clipboard.value.startedAt,
|
||||||
|
stoppedAt: clipboard.value.stoppedAt ?? undefined,
|
||||||
|
user: `/api/users/${selectedUserId.value}`,
|
||||||
|
project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
|
||||||
|
tags: clipboard.value.tags.map((t) => `/api/task_tags/${t.id}`),
|
||||||
|
})
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updatePageHeaderHeight()
|
||||||
|
|
||||||
|
if (!pageHeaderEl.value || typeof ResizeObserver === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||||
|
updatePageHeaderHeight()
|
||||||
|
})
|
||||||
|
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
pageHeaderResizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function onDelete(entry: TimeEntry) {
|
||||||
|
await timeEntryService.remove(entry.id)
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
const end = new Date(startDate.value)
|
||||||
|
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||||
|
|
||||||
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
|
after: startDate.value.toISOString(),
|
||||||
|
before: end.toISOString(),
|
||||||
|
user: selectedUserId.value ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReferenceData() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const [usersData, projectsData, typesData] = await Promise.all([
|
||||||
|
api.get<any>('/users'),
|
||||||
|
api.get<any>('/projects'),
|
||||||
|
api.get<any>('/task_tags'),
|
||||||
|
])
|
||||||
|
|
||||||
|
users.value = extractHydraMembers(usersData)
|
||||||
|
projects.value = extractHydraMembers(projectsData)
|
||||||
|
tags.value = extractHydraMembers(typesData)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadReferenceData()
|
||||||
|
await loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(viewMode, () => {
|
||||||
|
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedUserId, () => {
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
32
frontend/services/clients.ts
Normal file
32
frontend/services/clients.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Client, ClientWrite } from './dto/client'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useClientService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<Client[]> {
|
||||||
|
const data = await api.get<HydraCollection<Client>>('/clients')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: ClientWrite): Promise<Client> {
|
||||||
|
return api.post<Client>('/clients', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'clients.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<ClientWrite>): Promise<Client> {
|
||||||
|
return api.patch<Client>(`/clients/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'clients.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/clients/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'clients.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
19
frontend/services/dto/client.ts
Normal file
19
frontend/services/dto/client.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type Client = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
name: string
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
street: string | null
|
||||||
|
city: string | null
|
||||||
|
postalCode: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientWrite = {
|
||||||
|
name: string
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
street: string | null
|
||||||
|
city: string | null
|
||||||
|
postalCode: string | null
|
||||||
|
}
|
||||||
19
frontend/services/dto/project.ts
Normal file
19
frontend/services/dto/project.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Client } from './client'
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
color: string
|
||||||
|
client: Client | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectWrite = {
|
||||||
|
code?: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
color: string
|
||||||
|
client: string | null // IRI : "/api/clients/1" ou null
|
||||||
|
}
|
||||||
9
frontend/services/dto/task-effort.ts
Normal file
9
frontend/services/dto/task-effort.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type TaskEffort = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskEffortWrite = {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
19
frontend/services/dto/task-group.ts
Normal file
19
frontend/services/dto/task-group.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Project } from './project'
|
||||||
|
|
||||||
|
export type TaskGroup = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
color: string
|
||||||
|
project: Project | null
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskGroupWrite = {
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
color: string
|
||||||
|
project: string
|
||||||
|
archived?: boolean
|
||||||
|
}
|
||||||
11
frontend/services/dto/task-priority.ts
Normal file
11
frontend/services/dto/task-priority.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type TaskPriority = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskPriorityWrite = {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
15
frontend/services/dto/task-status.ts
Normal file
15
frontend/services/dto/task-status.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type TaskStatus = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
position: number
|
||||||
|
isFinal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskStatusWrite = {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
position: number
|
||||||
|
isFinal: boolean
|
||||||
|
}
|
||||||
11
frontend/services/dto/task-tag.ts
Normal file
11
frontend/services/dto/task-tag.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type TaskTag = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskTagWrite = {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
36
frontend/services/dto/task.ts
Normal file
36
frontend/services/dto/task.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { TaskStatus } from './task-status'
|
||||||
|
import type { TaskEffort } from './task-effort'
|
||||||
|
import type { TaskPriority } from './task-priority'
|
||||||
|
import type { TaskTag } from './task-tag'
|
||||||
|
import type { TaskGroup } from './task-group'
|
||||||
|
import type { UserData } from './user-data'
|
||||||
|
import type { Project } from './project'
|
||||||
|
|
||||||
|
export type Task = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: TaskStatus | null
|
||||||
|
effort: TaskEffort | null
|
||||||
|
priority: TaskPriority | null
|
||||||
|
assignee: UserData | null
|
||||||
|
group: TaskGroup | null
|
||||||
|
project: Project | null
|
||||||
|
tags: TaskTag[]
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskWrite = {
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: string | null
|
||||||
|
effort: string | null
|
||||||
|
priority: string | null
|
||||||
|
assignee: string | null
|
||||||
|
group: string | null
|
||||||
|
project: string
|
||||||
|
tags: string[]
|
||||||
|
archived?: boolean
|
||||||
|
}
|
||||||
28
frontend/services/dto/time-entry.ts
Normal file
28
frontend/services/dto/time-entry.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { UserData } from './user-data'
|
||||||
|
import type { Project } from './project'
|
||||||
|
import type { Task } from './task'
|
||||||
|
import type { TaskTag } from './task-tag'
|
||||||
|
|
||||||
|
export type TimeEntry = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
title: string | null
|
||||||
|
description: string | null
|
||||||
|
startedAt: string
|
||||||
|
stoppedAt: string | null
|
||||||
|
user: UserData
|
||||||
|
project: Project | null
|
||||||
|
task: Task | null
|
||||||
|
tags: TaskTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeEntryWrite = {
|
||||||
|
title?: string | null
|
||||||
|
description?: string | null
|
||||||
|
startedAt: string
|
||||||
|
stoppedAt?: string | null
|
||||||
|
user: string
|
||||||
|
project?: string | null
|
||||||
|
task?: string | null
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
'@id'?: string
|
||||||
roles: string[]
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserWrite = {
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
36
frontend/services/projects.ts
Normal file
36
frontend/services/projects.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Project, ProjectWrite } from './dto/project'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useProjectService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<Project[]> {
|
||||||
|
const data = await api.get<HydraCollection<Project>>('/projects')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id: number): Promise<Project> {
|
||||||
|
return api.get<Project>(`/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: ProjectWrite): Promise<Project> {
|
||||||
|
return api.post<Project>('/projects', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'projects.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<ProjectWrite>): Promise<Project> {
|
||||||
|
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'projects.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/projects/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'projects.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getById, create, update, remove }
|
||||||
|
}
|
||||||
32
frontend/services/task-efforts.ts
Normal file
32
frontend/services/task-efforts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { TaskEffort, TaskEffortWrite } from './dto/task-effort'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskEffortService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<TaskEffort[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskEffort>>('/task_efforts')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskEffortWrite): Promise<TaskEffort> {
|
||||||
|
return api.post<TaskEffort>('/task_efforts', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskEfforts.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskEffortWrite>): Promise<TaskEffort> {
|
||||||
|
return api.patch<TaskEffort>(`/task_efforts/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskEfforts.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_efforts/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskEfforts.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
39
frontend/services/task-groups.ts
Normal file
39
frontend/services/task-groups.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TaskGroup, TaskGroupWrite } from './dto/task-group'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskGroupService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<TaskGroup[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByProject(projectId: number): Promise<TaskGroup[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups', {
|
||||||
|
project: `/api/projects/${projectId}`,
|
||||||
|
})
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskGroupWrite): Promise<TaskGroup> {
|
||||||
|
return api.post<TaskGroup>('/task_groups', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskGroups.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskGroupWrite>): Promise<TaskGroup> {
|
||||||
|
return api.patch<TaskGroup>(`/task_groups/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskGroups.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_groups/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskGroups.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getByProject, create, update, remove }
|
||||||
|
}
|
||||||
32
frontend/services/task-priorities.ts
Normal file
32
frontend/services/task-priorities.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { TaskPriority, TaskPriorityWrite } from './dto/task-priority'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskPriorityService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<TaskPriority[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskPriority>>('/task_priorities')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskPriorityWrite): Promise<TaskPriority> {
|
||||||
|
return api.post<TaskPriority>('/task_priorities', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskPriorities.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskPriorityWrite>): Promise<TaskPriority> {
|
||||||
|
return api.patch<TaskPriority>(`/task_priorities/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskPriorities.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_priorities/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskPriorities.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
32
frontend/services/task-statuses.ts
Normal file
32
frontend/services/task-statuses.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskStatusService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<TaskStatus[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
|
||||||
|
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskStatuses.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
|
||||||
|
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskStatuses.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_statuses/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskStatuses.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
32
frontend/services/task-tags.ts
Normal file
32
frontend/services/task-tags.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { TaskTag, TaskTagWrite } from './dto/task-tag'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskTagService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<TaskTag[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskTag>>('/task_tags')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskTagWrite): Promise<TaskTag> {
|
||||||
|
return api.post<TaskTag>('/task_tags', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskTags.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskTagWrite>): Promise<TaskTag> {
|
||||||
|
return api.patch<TaskTag>(`/task_tags/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'taskTags.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/task_tags/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'taskTags.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
48
frontend/services/tasks.ts
Normal file
48
frontend/services/tasks.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Task, TaskWrite } from './dto/task'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTaskService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<Task[]> {
|
||||||
|
const data = await api.get<HydraCollection<Task>>('/tasks')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByProject(projectId: number): Promise<Task[]> {
|
||||||
|
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
||||||
|
project: `/api/projects/${projectId}`,
|
||||||
|
archived: false,
|
||||||
|
})
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByProjectArchived(projectId: number): Promise<Task[]> {
|
||||||
|
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
||||||
|
project: `/api/projects/${projectId}`,
|
||||||
|
archived: true,
|
||||||
|
})
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TaskWrite): Promise<Task> {
|
||||||
|
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'tasks.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TaskWrite>): Promise<Task> {
|
||||||
|
return api.patch<Task>(`/tasks/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'tasks.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/tasks/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'tasks.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getByProject, getByProjectArchived, create, update, remove }
|
||||||
|
}
|
||||||
54
frontend/services/time-entries.ts
Normal file
54
frontend/services/time-entries.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { TimeEntry, TimeEntryWrite } from './dto/time-entry'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTimeEntryService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getByDateRange(params: {
|
||||||
|
after: string
|
||||||
|
before: string
|
||||||
|
user?: number
|
||||||
|
types?: number[]
|
||||||
|
}): Promise<TimeEntry[]> {
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
'startedAt[after]': params.after,
|
||||||
|
'startedAt[before]': params.before,
|
||||||
|
}
|
||||||
|
if (params.user) {
|
||||||
|
query.user = `/api/users/${params.user}`
|
||||||
|
}
|
||||||
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActive(): Promise<TimeEntry | null> {
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries/active', {}, { toast: false })
|
||||||
|
const members = extractHydraMembers(data)
|
||||||
|
return members[0] ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
|
||||||
|
return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'timeEntries.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
|
||||||
|
return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'timeEntries.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/time_entries/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'timeEntries.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getByDateRange, getActive, create, update, remove }
|
||||||
|
}
|
||||||
32
frontend/services/users.ts
Normal file
32
frontend/services/users.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { UserData, UserWrite } from './dto/user-data'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useUserService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getAll(): Promise<UserData[]> {
|
||||||
|
const data = await api.get<HydraCollection<UserData>>('/users')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: UserWrite): Promise<UserData> {
|
||||||
|
return api.post<UserData>('/users', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'users.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<UserWrite>): Promise<UserData> {
|
||||||
|
return api.patch<UserData>(`/users/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'users.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/users/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'users.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
124
frontend/stores/timer.ts
Normal file
124
frontend/stores/timer.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
|
||||||
|
export const useTimerStore = defineStore('timer', () => {
|
||||||
|
const activeEntry = ref<TimeEntry | null>(null)
|
||||||
|
const pendingCompleteEntry = ref<TimeEntry | null>(null)
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const isRunning = computed(() => activeEntry.value !== null)
|
||||||
|
|
||||||
|
const elapsed = computed(() => {
|
||||||
|
if (!activeEntry.value) return 0
|
||||||
|
const start = new Date(activeEntry.value.startedAt).getTime()
|
||||||
|
return Math.floor((now.value - start) / 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsedFormatted = computed(() => {
|
||||||
|
const total = elapsed.value
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const m = Math.floor((total % 3600) / 60)
|
||||||
|
const s = total % 60
|
||||||
|
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':')
|
||||||
|
})
|
||||||
|
|
||||||
|
function startTicking() {
|
||||||
|
stopTicking()
|
||||||
|
now.value = Date.now()
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTicking() {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActive() {
|
||||||
|
const { getActive } = useTimeEntryService()
|
||||||
|
activeEntry.value = await getActive()
|
||||||
|
if (activeEntry.value) {
|
||||||
|
startTicking()
|
||||||
|
} else {
|
||||||
|
stopTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.user) return
|
||||||
|
|
||||||
|
if (isRunning.value) {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { create } = useTimeEntryService()
|
||||||
|
activeEntry.value = await create({
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
user: `/api/users/${authStore.user.id}`,
|
||||||
|
})
|
||||||
|
startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFromTask(task: Task) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.user) return
|
||||||
|
|
||||||
|
if (isRunning.value) {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { create } = useTimeEntryService()
|
||||||
|
activeEntry.value = await create({
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
user: `/api/users/${authStore.user.id}`,
|
||||||
|
title: task.title,
|
||||||
|
project: task.project
|
||||||
|
? (typeof task.project === 'string' ? task.project : (task.project['@id'] ?? (task.project.id ? `/api/projects/${task.project.id}` : null)))
|
||||||
|
: null,
|
||||||
|
task: typeof task === 'string' ? task : (task['@id'] ?? `/api/tasks/${task.id}`),
|
||||||
|
tags: task.tags?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_tags/${t.id}`)) ?? [],
|
||||||
|
})
|
||||||
|
startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!activeEntry.value) return
|
||||||
|
|
||||||
|
const wasEmpty = !activeEntry.value.task
|
||||||
|
|
||||||
|
const { update } = useTimeEntryService()
|
||||||
|
const stoppedEntry = await update(activeEntry.value.id, {
|
||||||
|
stoppedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
activeEntry.value = null
|
||||||
|
stopTicking()
|
||||||
|
|
||||||
|
if (wasEmpty) {
|
||||||
|
pendingCompleteEntry.value = stoppedEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingEntry() {
|
||||||
|
pendingCompleteEntry.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeEntry,
|
||||||
|
pendingCompleteEntry,
|
||||||
|
isRunning,
|
||||||
|
elapsed,
|
||||||
|
elapsedFormatted,
|
||||||
|
fetchActive,
|
||||||
|
start,
|
||||||
|
startFromTask,
|
||||||
|
stop,
|
||||||
|
clearPendingEntry,
|
||||||
|
}
|
||||||
|
})
|
||||||
22
frontend/stores/ui.ts
Normal file
22
frontend/stores/ui.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
||||||
|
if (saved !== null) {
|
||||||
|
sidebarCollapsed.value = saved === 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (val) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('ui-sidebar-collapsed', String(val))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sidebarCollapsed, toggleSidebar }
|
||||||
|
})
|
||||||
10
frontend/utils/api.ts
Normal file
10
frontend/utils/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type HydraCollection<T> = {
|
||||||
|
'hydra:member'?: T[]
|
||||||
|
'hydra:totalItems'?: number
|
||||||
|
'member'?: T[]
|
||||||
|
'totalItems'?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHydraMembers<T>(response: HydraCollection<T>): T[] {
|
||||||
|
return response['hydra:member'] ?? response['member'] ?? []
|
||||||
|
}
|
||||||
31
migrations/Version20260309213629.php
Normal file
31
migrations/Version20260309213629.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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 Version20260309213629 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 client (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(20) DEFAULT NULL, PRIMARY KEY (id))');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP TABLE client');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
migrations/Version20260309213906.php
Normal file
34
migrations/Version20260309213906.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?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 Version20260309213906 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 project (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, color VARCHAR(7) NOT NULL, client_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_2FB3D0EE19EB6921 ON project (client_id)');
|
||||||
|
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE19EB6921 FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EE19EB6921');
|
||||||
|
$this->addSql('DROP TABLE project');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
migrations/Version20260309221052.php
Normal file
70
migrations/Version20260309221052.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?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 Version20260309221052 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 (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, status_id INT DEFAULT NULL, effort_id INT DEFAULT NULL, priority_id INT DEFAULT NULL, assignee_id INT DEFAULT NULL, group_id INT DEFAULT NULL, project_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB256BF700BD ON task (status_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB259F2256F ON task (effort_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB25497B19F9 ON task (priority_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB2559EC7D60 ON task (assignee_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB25FE54D947 ON task (group_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_527EDB25166D1F9C ON task (project_id)');
|
||||||
|
$this->addSql('CREATE TABLE task_task_type (task_id INT NOT NULL, task_type_id INT NOT NULL, PRIMARY KEY (task_id, task_type_id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_80470E038DB60186 ON task_task_type (task_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_80470E03DAADA679 ON task_task_type (task_type_id)');
|
||||||
|
$this->addSql('CREATE TABLE task_effort (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(50) NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE TABLE task_group (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, color VARCHAR(7) NOT NULL, project_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_AA645FE5166D1F9C ON task_group (project_id)');
|
||||||
|
$this->addSql('CREATE TABLE task_priority (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, color VARCHAR(7) NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE TABLE task_status (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, color VARCHAR(7) NOT NULL, position INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE TABLE task_type (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, color VARCHAR(7) NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB256BF700BD FOREIGN KEY (status_id) REFERENCES task_status (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB259F2256F FOREIGN KEY (effort_id) REFERENCES task_effort (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25497B19F9 FOREIGN KEY (priority_id) REFERENCES task_priority (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB2559EC7D60 FOREIGN KEY (assignee_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25FE54D947 FOREIGN KEY (group_id) REFERENCES task_group (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT FK_80470E038DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT FK_80470E03DAADA679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('ALTER TABLE task_group ADD CONSTRAINT FK_AA645FE5166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB256BF700BD');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB259F2256F');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB25497B19F9');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB2559EC7D60');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB25FE54D947');
|
||||||
|
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB25166D1F9C');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT FK_80470E038DB60186');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT FK_80470E03DAADA679');
|
||||||
|
$this->addSql('ALTER TABLE task_group DROP CONSTRAINT FK_AA645FE5166D1F9C');
|
||||||
|
$this->addSql('DROP TABLE task');
|
||||||
|
$this->addSql('DROP TABLE task_task_type');
|
||||||
|
$this->addSql('DROP TABLE task_effort');
|
||||||
|
$this->addSql('DROP TABLE task_group');
|
||||||
|
$this->addSql('DROP TABLE task_priority');
|
||||||
|
$this->addSql('DROP TABLE task_status');
|
||||||
|
$this->addSql('DROP TABLE task_type');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260310201845.php
Normal file
35
migrations/Version20260310201845.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260310201845 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('ALTER TABLE task_status ADD project_id INT NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_40A9E1CF166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_40A9E1CF166D1F9C ON task_status (project_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_40A9E1CF166D1F9C');
|
||||||
|
$this->addSql('DROP INDEX IDX_40A9E1CF166D1F9C');
|
||||||
|
$this->addSql('ALTER TABLE task_status DROP project_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
migrations/Version20260310211017.php
Normal file
49
migrations/Version20260310211017.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?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 Version20260310211017 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 time_entry (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, started_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, stopped_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, user_id INT NOT NULL, project_id INT DEFAULT NULL, task_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6E537C0CA76ED395 ON time_entry (user_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6E537C0C166D1F9C ON time_entry (project_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6E537C0C8DB60186 ON time_entry (task_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_active_timer ON time_entry (user_id) WHERE (stopped_at IS NULL)');
|
||||||
|
$this->addSql('CREATE TABLE time_entry_task_type (time_entry_id INT NOT NULL, task_type_id INT NOT NULL, PRIMARY KEY (time_entry_id, task_type_id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BE7A719D1EB30A8E ON time_entry_task_type (time_entry_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BE7A719DDAADA679 ON time_entry_task_type (task_type_id)');
|
||||||
|
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0CA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C8DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT FK_BE7A719D1EB30A8E FOREIGN KEY (time_entry_id) REFERENCES time_entry (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT FK_BE7A719DDAADA679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) ON DELETE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0CA76ED395');
|
||||||
|
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C166D1F9C');
|
||||||
|
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C8DB60186');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT FK_BE7A719D1EB30A8E');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT FK_BE7A719DDAADA679');
|
||||||
|
$this->addSql('DROP TABLE time_entry');
|
||||||
|
$this->addSql('DROP TABLE time_entry_task_type');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260312104832.php
Normal file
35
migrations/Version20260312104832.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260312104832 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('ALTER TABLE task_status DROP CONSTRAINT fk_40a9e1cf166d1f9c');
|
||||||
|
$this->addSql('DROP INDEX idx_40a9e1cf166d1f9c');
|
||||||
|
$this->addSql('ALTER TABLE task_status DROP project_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE task_status ADD project_id INT NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT fk_40a9e1cf166d1f9c FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE INDEX idx_40a9e1cf166d1f9c ON task_status (project_id)');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260312111449.php
Normal file
35
migrations/Version20260312111449.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260312111449 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('ALTER TABLE project ADD code VARCHAR(10) NOT NULL');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_2FB3D0EE77153098 ON project (code)');
|
||||||
|
$this->addSql('ALTER TABLE task ADD number INT NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP INDEX UNIQ_2FB3D0EE77153098');
|
||||||
|
$this->addSql('ALTER TABLE project DROP code');
|
||||||
|
$this->addSql('ALTER TABLE task DROP number');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
migrations/Version20260312165317.php
Normal file
51
migrations/Version20260312165317.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?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 Version20260312165317 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('ALTER TABLE task ADD archived BOOLEAN NOT NULL DEFAULT FALSE');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT fk_80470e038db60186');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT fk_80470e03daada679');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT FK_80470E038DB60186 FOREIGN KEY (task_id) REFERENCES task (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT FK_80470E03DAADA679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task_group ADD archived BOOLEAN NOT NULL DEFAULT FALSE');
|
||||||
|
$this->addSql('ALTER TABLE task_status ADD is_final BOOLEAN NOT NULL DEFAULT FALSE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT fk_be7a719d1eb30a8e');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT fk_be7a719ddaada679');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT FK_BE7A719D1EB30A8E FOREIGN KEY (time_entry_id) REFERENCES time_entry (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT FK_BE7A719DDAADA679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) NOT DEFERRABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE task DROP archived');
|
||||||
|
$this->addSql('ALTER TABLE task_group DROP archived');
|
||||||
|
$this->addSql('ALTER TABLE task_status DROP is_final');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT FK_80470E038DB60186');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type DROP CONSTRAINT FK_80470E03DAADA679');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT fk_80470e038db60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE task_task_type ADD CONSTRAINT fk_80470e03daada679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT FK_BE7A719D1EB30A8E');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type DROP CONSTRAINT FK_BE7A719DDAADA679');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT fk_be7a719d1eb30a8e FOREIGN KEY (time_entry_id) REFERENCES time_entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE time_entry_task_type ADD CONSTRAINT fk_be7a719ddaada679 FOREIGN KEY (task_type_id) REFERENCES task_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\DataFixtures;
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Client;
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Entity\Task;
|
||||||
|
use App\Entity\TaskEffort;
|
||||||
|
use App\Entity\TaskGroup;
|
||||||
|
use App\Entity\TaskPriority;
|
||||||
|
use App\Entity\TaskStatus;
|
||||||
|
use App\Entity\TaskTag;
|
||||||
|
use App\Entity\TimeEntry;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
@@ -17,12 +28,267 @@ class AppFixtures extends Fixture
|
|||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
|
// User admin
|
||||||
$admin = new User();
|
$admin = new User();
|
||||||
$admin->setUsername('admin');
|
$admin->setUsername('admin');
|
||||||
$admin->setRoles(['ROLE_ADMIN']);
|
$admin->setRoles(['ROLE_ADMIN']);
|
||||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||||
$manager->persist($admin);
|
$manager->persist($admin);
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
$clientLiot = new Client();
|
||||||
|
$clientLiot->setName('LIOT');
|
||||||
|
$clientLiot->setEmail('contact@liot.fr');
|
||||||
|
$clientLiot->setPhone('05 50 50 50 50');
|
||||||
|
$clientLiot->setStreet('14 allée d\'argenson');
|
||||||
|
$clientLiot->setCity('Poitiers');
|
||||||
|
$clientLiot->setPostalCode('86100');
|
||||||
|
$manager->persist($clientLiot);
|
||||||
|
|
||||||
|
$clientAcme = new Client();
|
||||||
|
$clientAcme->setName('ACME Corp');
|
||||||
|
$clientAcme->setEmail('contact@acme.com');
|
||||||
|
$clientAcme->setPhone('01 23 45 67 89');
|
||||||
|
$clientAcme->setStreet('10 rue de la Paix');
|
||||||
|
$clientAcme->setCity('Paris');
|
||||||
|
$clientAcme->setPostalCode('75002');
|
||||||
|
$manager->persist($clientAcme);
|
||||||
|
|
||||||
|
$clientNova = new Client();
|
||||||
|
$clientNova->setName('Nova Tech');
|
||||||
|
$clientNova->setEmail('info@novatech.io');
|
||||||
|
$clientNova->setPhone('04 56 78 90 12');
|
||||||
|
$clientNova->setStreet('5 avenue Jean Jaurès');
|
||||||
|
$clientNova->setCity('Lyon');
|
||||||
|
$clientNova->setPostalCode('69007');
|
||||||
|
$manager->persist($clientNova);
|
||||||
|
|
||||||
|
// Projets
|
||||||
|
$projectSirh = new Project();
|
||||||
|
$projectSirh->setCode('SIRH');
|
||||||
|
$projectSirh->setName('SIRH');
|
||||||
|
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
|
||||||
|
$projectSirh->setColor('#222783');
|
||||||
|
$projectSirh->setClient($clientLiot);
|
||||||
|
$manager->persist($projectSirh);
|
||||||
|
|
||||||
|
$projectCrm = new Project();
|
||||||
|
$projectCrm->setCode('CRM');
|
||||||
|
$projectCrm->setName('CRM');
|
||||||
|
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
|
||||||
|
$projectCrm->setColor('#E91E63');
|
||||||
|
$projectCrm->setClient($clientAcme);
|
||||||
|
$manager->persist($projectCrm);
|
||||||
|
|
||||||
|
$projectErp = new Project();
|
||||||
|
$projectErp->setCode('ERP');
|
||||||
|
$projectErp->setName('ERP');
|
||||||
|
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||||
|
$projectErp->setColor('#4A90D9');
|
||||||
|
$projectErp->setClient($clientNova);
|
||||||
|
$manager->persist($projectErp);
|
||||||
|
|
||||||
|
$projectInterne = new Project();
|
||||||
|
$projectInterne->setCode('SITE');
|
||||||
|
$projectInterne->setName('Site vitrine');
|
||||||
|
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||||
|
$projectInterne->setColor('#26A69A');
|
||||||
|
$projectInterne->setClient(null);
|
||||||
|
$manager->persist($projectInterne);
|
||||||
|
|
||||||
|
// Task Statuses (global)
|
||||||
|
$defaultStatuses = [
|
||||||
|
['A faire', '#222783', 0],
|
||||||
|
['En cours', '#4A90D9', 1],
|
||||||
|
['Bloqué', '#C62828', 2],
|
||||||
|
['En attente de validation', '#FF8F00', 3],
|
||||||
|
['Terminé', '#26A69A', 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
$statusObjects = [];
|
||||||
|
foreach ($defaultStatuses as [$label, $color, $position]) {
|
||||||
|
$status = new TaskStatus();
|
||||||
|
$status->setLabel($label);
|
||||||
|
$status->setColor($color);
|
||||||
|
$status->setPosition($position);
|
||||||
|
if ('Terminé' === $label) {
|
||||||
|
$status->setIsFinal(true);
|
||||||
|
}
|
||||||
|
$manager->persist($status);
|
||||||
|
$statusObjects[$label] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusTodo = $statusObjects['A faire'];
|
||||||
|
$statusInProgress = $statusObjects['En cours'];
|
||||||
|
$statusBlocked = $statusObjects['Bloqué'];
|
||||||
|
$statusReview = $statusObjects['En attente de validation'];
|
||||||
|
$statusDone = $statusObjects['Terminé'];
|
||||||
|
|
||||||
|
// Task Efforts
|
||||||
|
$effortS = new TaskEffort();
|
||||||
|
$effortS->setLabel('S');
|
||||||
|
$manager->persist($effortS);
|
||||||
|
|
||||||
|
$effortM = new TaskEffort();
|
||||||
|
$effortM->setLabel('M');
|
||||||
|
$manager->persist($effortM);
|
||||||
|
|
||||||
|
$effortL = new TaskEffort();
|
||||||
|
$effortL->setLabel('L');
|
||||||
|
$manager->persist($effortL);
|
||||||
|
|
||||||
|
$effortXL = new TaskEffort();
|
||||||
|
$effortXL->setLabel('XL');
|
||||||
|
$manager->persist($effortXL);
|
||||||
|
|
||||||
|
$effortXXL = new TaskEffort();
|
||||||
|
$effortXXL->setLabel('XXL');
|
||||||
|
$manager->persist($effortXXL);
|
||||||
|
|
||||||
|
// Task Priorities
|
||||||
|
$priorityLow = new TaskPriority();
|
||||||
|
$priorityLow->setLabel('Basse');
|
||||||
|
$priorityLow->setColor('#222783');
|
||||||
|
$manager->persist($priorityLow);
|
||||||
|
|
||||||
|
$priorityMedium = new TaskPriority();
|
||||||
|
$priorityMedium->setLabel('Moyen');
|
||||||
|
$priorityMedium->setColor('#FF8F00');
|
||||||
|
$manager->persist($priorityMedium);
|
||||||
|
|
||||||
|
$priorityHigh = new TaskPriority();
|
||||||
|
$priorityHigh->setLabel('Haute');
|
||||||
|
$priorityHigh->setColor('#C62828');
|
||||||
|
$manager->persist($priorityHigh);
|
||||||
|
|
||||||
|
// Task Tags
|
||||||
|
$tagPassword = new TaskTag();
|
||||||
|
$tagPassword->setLabel('Gestion mdp');
|
||||||
|
$tagPassword->setColor('#C62828');
|
||||||
|
$manager->persist($tagPassword);
|
||||||
|
|
||||||
|
$tagAuth = new TaskTag();
|
||||||
|
$tagAuth->setLabel('Connexion');
|
||||||
|
$tagAuth->setColor('#FF8F00');
|
||||||
|
$manager->persist($tagAuth);
|
||||||
|
|
||||||
|
$tagCalendar = new TaskTag();
|
||||||
|
$tagCalendar->setLabel('Calendrier');
|
||||||
|
$tagCalendar->setColor('#222783');
|
||||||
|
$manager->persist($tagCalendar);
|
||||||
|
|
||||||
|
// Task Groups
|
||||||
|
$groupFrontend = new TaskGroup();
|
||||||
|
$groupFrontend->setTitle('Frontend');
|
||||||
|
$groupFrontend->setColor('#4A90D9');
|
||||||
|
$groupFrontend->setProject($projectSirh);
|
||||||
|
$manager->persist($groupFrontend);
|
||||||
|
|
||||||
|
$groupBackend = new TaskGroup();
|
||||||
|
$groupBackend->setTitle('Backend');
|
||||||
|
$groupBackend->setColor('#26A69A');
|
||||||
|
$groupBackend->setProject($projectSirh);
|
||||||
|
$manager->persist($groupBackend);
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
$task1 = new Task();
|
||||||
|
$task1->setNumber(1);
|
||||||
|
$task1->setTitle('Création d\'une page de login');
|
||||||
|
$task1->setStatus($statusTodo);
|
||||||
|
$task1->setEffort($effortXXL);
|
||||||
|
$task1->setPriority($priorityLow);
|
||||||
|
$task1->setAssignee($admin);
|
||||||
|
$task1->setGroup($groupFrontend);
|
||||||
|
$task1->setProject($projectSirh);
|
||||||
|
$task1->addTag($tagPassword);
|
||||||
|
$manager->persist($task1);
|
||||||
|
|
||||||
|
$task2 = new Task();
|
||||||
|
$task2->setNumber(2);
|
||||||
|
$task2->setTitle('Intégration SSO');
|
||||||
|
$task2->setStatus($statusTodo);
|
||||||
|
$task2->setEffort($effortL);
|
||||||
|
$task2->setPriority($priorityHigh);
|
||||||
|
$task2->setAssignee($admin);
|
||||||
|
$task2->setGroup($groupFrontend);
|
||||||
|
$task2->setProject($projectSirh);
|
||||||
|
$task2->addTag($tagAuth);
|
||||||
|
$manager->persist($task2);
|
||||||
|
|
||||||
|
$task3 = new Task();
|
||||||
|
$task3->setNumber(3);
|
||||||
|
$task3->setTitle('API d\'authentification');
|
||||||
|
$task3->setStatus($statusInProgress);
|
||||||
|
$task3->setEffort($effortXXL);
|
||||||
|
$task3->setPriority($priorityLow);
|
||||||
|
$task3->setAssignee($admin);
|
||||||
|
$task3->setGroup($groupBackend);
|
||||||
|
$task3->setProject($projectSirh);
|
||||||
|
$task3->addTag($tagPassword);
|
||||||
|
$manager->persist($task3);
|
||||||
|
|
||||||
|
$task4 = new Task();
|
||||||
|
$task4->setNumber(4);
|
||||||
|
$task4->setTitle('Gestion des tokens JWT');
|
||||||
|
$task4->setStatus($statusBlocked);
|
||||||
|
$task4->setEffort($effortXXL);
|
||||||
|
$task4->setPriority($priorityLow);
|
||||||
|
$task4->setAssignee($admin);
|
||||||
|
$task4->setProject($projectSirh);
|
||||||
|
$task4->addTag($tagPassword);
|
||||||
|
$manager->persist($task4);
|
||||||
|
|
||||||
|
$task5 = new Task();
|
||||||
|
$task5->setNumber(5);
|
||||||
|
$task5->setTitle('Calendrier des congés');
|
||||||
|
$task5->setStatus($statusReview);
|
||||||
|
$task5->setEffort($effortXXL);
|
||||||
|
$task5->setPriority($priorityMedium);
|
||||||
|
$task5->setAssignee($admin);
|
||||||
|
$task5->setProject($projectSirh);
|
||||||
|
$task5->addTag($tagCalendar);
|
||||||
|
$manager->persist($task5);
|
||||||
|
|
||||||
|
$task6 = new Task();
|
||||||
|
$task6->setNumber(6);
|
||||||
|
$task6->setTitle('Page de réinitialisation mdp');
|
||||||
|
$task6->setStatus($statusDone);
|
||||||
|
$task6->setEffort($effortXXL);
|
||||||
|
$task6->setPriority($priorityHigh);
|
||||||
|
$task6->setAssignee($admin);
|
||||||
|
$task6->setProject($projectSirh);
|
||||||
|
$task6->addTag($tagAuth);
|
||||||
|
$manager->persist($task6);
|
||||||
|
|
||||||
|
// --- Time Entries (SIRH project, admin user) ---
|
||||||
|
$timeEntryData = [
|
||||||
|
['title' => 'Réunion', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:00', 'stop' => '09:45', 'day' => 1],
|
||||||
|
['title' => 'Page accueil', 'project' => $projectSirh, 'tag' => $tagPassword, 'start' => '10:00', 'stop' => '12:00', 'day' => 0],
|
||||||
|
['title' => 'Design admin', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:30', 'stop' => '11:00', 'day' => 2],
|
||||||
|
['title' => 'Page accueil', 'project' => $projectSirh, 'tag' => $tagPassword, 'start' => '10:30', 'stop' => '12:15', 'day' => 1],
|
||||||
|
['title' => 'System os', 'project' => $projectSirh, 'tag' => $tagCalendar, 'start' => '13:00', 'stop' => '15:30', 'day' => 0],
|
||||||
|
['title' => 'Login', 'project' => $projectSirh, 'tag' => $tagPassword, 'start' => '13:00', 'stop' => '15:00', 'day' => 1],
|
||||||
|
['title' => 'Script vault', 'project' => $projectSirh, 'tag' => $tagCalendar, 'start' => '10:00', 'stop' => '12:00', 'day' => 3],
|
||||||
|
['title' => 'Script backup BDD', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '13:30', 'stop' => '15:00', 'day' => 3],
|
||||||
|
['title' => 'Maquette', 'project' => $projectSirh, 'tag' => null, 'start' => '09:00', 'stop' => '11:00', 'day' => 4],
|
||||||
|
['title' => 'PC compta', 'project' => $projectSirh, 'tag' => null, 'start' => '13:30', 'stop' => '15:30', 'day' => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
$monday = new DateTimeImmutable('monday this week', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
foreach ($timeEntryData as $data) {
|
||||||
|
$entry = new TimeEntry();
|
||||||
|
$entry->setTitle($data['title']);
|
||||||
|
$entry->setUser($admin);
|
||||||
|
$entry->setProject($data['project']);
|
||||||
|
$entry->setStartedAt($monday->modify("+{$data['day']} days")->modify($data['start']));
|
||||||
|
$entry->setStoppedAt($monday->modify("+{$data['day']} days")->modify($data['stop']));
|
||||||
|
if ($data['tag']) {
|
||||||
|
$entry->addTag($data['tag']);
|
||||||
|
}
|
||||||
|
$manager->persist($entry);
|
||||||
|
}
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/Entity/Client.php
Normal file
155
src/Entity/Client.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?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\Repository\ClientRepository;
|
||||||
|
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(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['client:read']],
|
||||||
|
denormalizationContext: ['groups' => ['client:write']],
|
||||||
|
order: ['name' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: ClientRepository::class)]
|
||||||
|
class Client
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['client:read', 'project:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['client:read', 'client:write', 'project:read'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['client:read', 'client:write'])]
|
||||||
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
#[Groups(['client:read', 'client:write'])]
|
||||||
|
private ?string $phone = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['client:read', 'client:write'])]
|
||||||
|
private ?string $street = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['client:read', 'client:write'])]
|
||||||
|
private ?string $city = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
#[Groups(['client:read', 'client:write'])]
|
||||||
|
private ?string $postalCode = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, Project> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'client')]
|
||||||
|
private Collection $projects;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->projects = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmail(?string $email): static
|
||||||
|
{
|
||||||
|
$this->email = $email;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPhone(): ?string
|
||||||
|
{
|
||||||
|
return $this->phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPhone(?string $phone): static
|
||||||
|
{
|
||||||
|
$this->phone = $phone;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStreet(): ?string
|
||||||
|
{
|
||||||
|
return $this->street;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStreet(?string $street): static
|
||||||
|
{
|
||||||
|
$this->street = $street;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCity(): ?string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCity(?string $city): static
|
||||||
|
{
|
||||||
|
$this->city = $city;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPostalCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->postalCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPostalCode(?string $postalCode): static
|
||||||
|
{
|
||||||
|
$this->postalCode = $postalCode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Project> */
|
||||||
|
public function getProjects(): Collection
|
||||||
|
{
|
||||||
|
return $this->projects;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/Entity/Project.php
Normal file
131
src/Entity/Project.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?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\Repository\ProjectRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
||||||
|
),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['project:read']],
|
||||||
|
denormalizationContext: ['groups' => ['project:write']],
|
||||||
|
order: ['name' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||||
|
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
|
||||||
|
class Project
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project:read', 'time_entry:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, unique: true)]
|
||||||
|
#[Groups(['project:read', 'project:create', 'task:read'])]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Regex(pattern: '/^[A-Z]{2,10}$/', message: 'Le code doit contenir entre 2 et 10 lettres majuscules.')]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?Client $client = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClient(): ?Client
|
||||||
|
{
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClient(?Client $client): static
|
||||||
|
{
|
||||||
|
$this->client = $client;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/Entity/Task.php
Normal file
253
src/Entity/Task.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
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\Repository\TaskRepository;
|
||||||
|
use App\State\TaskNumberProcessor;
|
||||||
|
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(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task:write']],
|
||||||
|
order: ['id' => 'DESC'],
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact'])]
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||||
|
class Task
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['task:read'])]
|
||||||
|
private ?int $number = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: TaskStatus::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?TaskStatus $status = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: TaskEffort::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?TaskEffort $effort = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: TaskPriority::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?TaskPriority $priority = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?User $assignee = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?TaskGroup $group = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private ?Project $project = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, TaskTag> */
|
||||||
|
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||||
|
#[ORM\JoinTable(
|
||||||
|
name: 'task_task_type',
|
||||||
|
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id')],
|
||||||
|
inverseJoinColumns: [new ORM\JoinColumn(name: 'task_type_id', referencedColumnName: 'id')],
|
||||||
|
)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private Collection $tags;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean')]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private bool $archived = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tags = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumber(): ?int
|
||||||
|
{
|
||||||
|
return $this->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumber(int $number): static
|
||||||
|
{
|
||||||
|
$this->number = $number;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(string $title): static
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): ?TaskStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(?TaskStatus $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEffort(): ?TaskEffort
|
||||||
|
{
|
||||||
|
return $this->effort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEffort(?TaskEffort $effort): static
|
||||||
|
{
|
||||||
|
$this->effort = $effort;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): ?TaskPriority
|
||||||
|
{
|
||||||
|
return $this->priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPriority(?TaskPriority $priority): static
|
||||||
|
{
|
||||||
|
$this->priority = $priority;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAssignee(): ?User
|
||||||
|
{
|
||||||
|
return $this->assignee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAssignee(?User $assignee): static
|
||||||
|
{
|
||||||
|
$this->assignee = $assignee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGroup(): ?TaskGroup
|
||||||
|
{
|
||||||
|
return $this->group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGroup(?TaskGroup $group): static
|
||||||
|
{
|
||||||
|
$this->group = $group;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProject(): ?Project
|
||||||
|
{
|
||||||
|
return $this->project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProject(?Project $project): static
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, TaskTag> */
|
||||||
|
public function getTags(): Collection
|
||||||
|
{
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTag(TaskTag $tag): static
|
||||||
|
{
|
||||||
|
if (!$this->tags->contains($tag)) {
|
||||||
|
$this->tags->add($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTag(TaskTag $tag): static
|
||||||
|
{
|
||||||
|
$this->tags->removeElement($tag);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isArchived(): bool
|
||||||
|
{
|
||||||
|
return $this->archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArchived(bool $archived): static
|
||||||
|
{
|
||||||
|
$this->archived = $archived;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Entity/TaskEffort.php
Normal file
58
src/Entity/TaskEffort.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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\Repository\TaskEffortRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_effort:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_effort:write']],
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskEffortRepository::class)]
|
||||||
|
class TaskEffort
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_effort:read', 'task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Groups(['task_effort:read', 'task_effort:write', 'task:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/Entity/TaskGroup.php
Normal file
128
src/Entity/TaskGroup.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
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\Repository\TaskGroupRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_group:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_group:write']],
|
||||||
|
order: ['title' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact'])]
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskGroupRepository::class)]
|
||||||
|
class TaskGroup
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_group:read', 'task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
|
||||||
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['task_group:read', 'task_group:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['task_group:read', 'task_group:write'])]
|
||||||
|
private ?Project $project = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean')]
|
||||||
|
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
|
||||||
|
private bool $archived = false;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(string $title): static
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProject(): ?Project
|
||||||
|
{
|
||||||
|
return $this->project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProject(?Project $project): static
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isArchived(): bool
|
||||||
|
{
|
||||||
|
return $this->archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArchived(bool $archived): static
|
||||||
|
{
|
||||||
|
$this->archived = $archived;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Entity/TaskPriority.php
Normal file
74
src/Entity/TaskPriority.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?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\Repository\TaskPriorityRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_priority:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_priority:write']],
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskPriorityRepository::class)]
|
||||||
|
class TaskPriority
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_priority:read', 'task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/Entity/TaskStatus.php
Normal file
106
src/Entity/TaskStatus.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?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\Repository\TaskStatusRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_status:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_status:write']],
|
||||||
|
order: ['position' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
|
||||||
|
class TaskStatus
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_status:read', 'task:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||||
|
private ?int $position = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean')]
|
||||||
|
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||||
|
private bool $isFinal = false;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): ?int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFinal(): bool
|
||||||
|
{
|
||||||
|
return $this->isFinal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsFinal(bool $isFinal): static
|
||||||
|
{
|
||||||
|
$this->isFinal = $isFinal;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Entity/TaskTag.php
Normal file
75
src/Entity/TaskTag.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?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\Repository\TaskTagRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_tag:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_tag:write']],
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskTagRepository::class)]
|
||||||
|
#[ORM\Table(name: 'task_type')]
|
||||||
|
class TaskTag
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_tag:read', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Entity/TaskType.php
Normal file
74
src/Entity/TaskType.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?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\Repository\TaskTypeRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['task_type:read']],
|
||||||
|
denormalizationContext: ['groups' => ['task_type:write']],
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: TaskTypeRepository::class)]
|
||||||
|
class TaskType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['task_type:read', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7)]
|
||||||
|
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
|
||||||
|
private ?string $color = '#222783';
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/Entity/TimeEntry.php
Normal file
212
src/Entity/TimeEntry.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
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\Repository\TimeEntryRepository;
|
||||||
|
use App\State\ActiveTimeEntryProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new GetCollection(
|
||||||
|
name: 'active_time_entry',
|
||||||
|
uriTemplate: '/time_entries/active',
|
||||||
|
provider: ActiveTimeEntryProvider::class,
|
||||||
|
description: 'Get the active timer for the current user',
|
||||||
|
paginationEnabled: false,
|
||||||
|
),
|
||||||
|
new Get(),
|
||||||
|
new Post(security: "is_granted('ROLE_USER')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['time_entry:read']],
|
||||||
|
denormalizationContext: ['groups' => ['time_entry:write']],
|
||||||
|
order: ['startedAt' => 'DESC'],
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
|
||||||
|
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
|
||||||
|
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
|
||||||
|
class TimeEntry
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['time_entry:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?DateTimeImmutable $startedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?DateTimeImmutable $stoppedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?Project $project = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Task::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private ?Task $task = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, TaskTag> */
|
||||||
|
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||||
|
#[ORM\JoinTable(
|
||||||
|
name: 'time_entry_task_type',
|
||||||
|
joinColumns: [new ORM\JoinColumn(name: 'time_entry_id', referencedColumnName: 'id')],
|
||||||
|
inverseJoinColumns: [new ORM\JoinColumn(name: 'task_type_id', referencedColumnName: 'id')],
|
||||||
|
)]
|
||||||
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
|
private Collection $tags;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tags = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(?string $title): static
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartedAt(DateTimeImmutable $startedAt): static
|
||||||
|
{
|
||||||
|
$this->startedAt = $startedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStoppedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->stoppedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStoppedAt(?DateTimeImmutable $stoppedAt): static
|
||||||
|
{
|
||||||
|
$this->stoppedAt = $stoppedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProject(): ?Project
|
||||||
|
{
|
||||||
|
return $this->project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProject(?Project $project): static
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTask(): ?Task
|
||||||
|
{
|
||||||
|
return $this->task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTask(?Task $task): static
|
||||||
|
{
|
||||||
|
$this->task = $task;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, TaskTag> */
|
||||||
|
public function getTags(): Collection
|
||||||
|
{
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTag(TaskTag $tag): static
|
||||||
|
{
|
||||||
|
if (!$this->tags->contains($tag)) {
|
||||||
|
$this->tags->add($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTag(TaskTag $tag): static
|
||||||
|
{
|
||||||
|
$this->tags->removeElement($tag);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\State\MeProvider;
|
use App\State\MeProvider;
|
||||||
|
use App\State\UserPasswordHasherProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -22,7 +27,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
provider: MeProvider::class,
|
provider: MeProvider::class,
|
||||||
normalizationContext: ['groups' => ['me:read']],
|
normalizationContext: ['groups' => ['me:read']],
|
||||||
),
|
),
|
||||||
|
new Get(
|
||||||
|
normalizationContext: ['groups' => ['user:list']],
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
normalizationContext: ['groups' => ['user:list']],
|
||||||
|
),
|
||||||
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
|
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
],
|
],
|
||||||
|
denormalizationContext: ['groups' => ['user:write']],
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
@@ -31,19 +46,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180, unique: true)]
|
#[ORM\Column(length: 180, unique: true)]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read'])]
|
||||||
private ?string $username = null;
|
private ?string $username = null;
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||||
private array $roles = [];
|
private array $roles = [];
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:write'])]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
|||||||
17
src/Repository/ClientRepository.php
Normal file
17
src/Repository/ClientRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Client;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class ClientRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Client::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/ProjectRepository.php
Normal file
17
src/Repository/ProjectRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Project;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class ProjectRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Project::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/TaskEffortRepository.php
Normal file
17
src/Repository/TaskEffortRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TaskEffort;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskEffortRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, TaskEffort::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/TaskGroupRepository.php
Normal file
17
src/Repository/TaskGroupRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TaskGroup;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskGroupRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, TaskGroup::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/TaskPriorityRepository.php
Normal file
17
src/Repository/TaskPriorityRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TaskPriority;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskPriorityRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, TaskPriority::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Repository/TaskRepository.php
Normal file
31
src/Repository/TaskRepository.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Entity\Task;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Task::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findMaxNumberByProject(Project $project): int
|
||||||
|
{
|
||||||
|
$result = $this->createQueryBuilder('t')
|
||||||
|
->select('MAX(t.number)')
|
||||||
|
->where('t.project = :project')
|
||||||
|
->setParameter('project', $project)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return (int) ($result ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/TaskStatusRepository.php
Normal file
17
src/Repository/TaskStatusRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TaskStatus;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class TaskStatusRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, TaskStatus::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user