Compare commits
21 Commits
v0.1.0
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
| bb332aa7e8 | |||
| fd6d0afb24 | |||
| 71e6e83c82 | |||
| 2f746ebce4 | |||
| 91da21d16b | |||
| 8c56ee6dd7 | |||
| 81797e10c0 | |||
| c7b1e62037 | |||
| ac11690ad4 | |||
| 0a7856b37c | |||
| 1d50e5dcb3 | |||
| b240dc6fc4 | |||
| 64ae634297 | |||
| bb45066013 | |||
| 9ba49cd29c | |||
| 5f57b377fa | |||
| b5efb54f71 | |||
| de7c2c25cd | |||
| b5dbab7dab | |||
| b56d2f6526 | |||
| 0621388ee6 |
13
CLAUDE.md
13
CLAUDE.md
@@ -5,28 +5,29 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Stack
|
||||
|
||||
- **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`
|
||||
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskType, TaskGroup)
|
||||
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, UserPasswordHasherProcessor)
|
||||
src/Repository/ # Repositories Doctrine
|
||||
src/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages
|
||||
frontend/pages/ # Pages (index, login, clients, projects, projects/[id], admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue
|
||||
frontend/components/ # Composants Vue (AppDrawer, ColorPicker, *Drawer, TaskCard, Admin*Tab, UserDrawer)
|
||||
frontend/composables/# Composables (useApi, etc.)
|
||||
frontend/stores/ # Stores Pinia
|
||||
frontend/services/ # Services API (auth, etc.)
|
||||
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, etc.)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
|
||||
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:
|
||||
title: Hello API Platform
|
||||
version: 1.0.0
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
patch_formats:
|
||||
json: ['application/merge-patch+json']
|
||||
defaults:
|
||||
stateless: true
|
||||
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
96
frontend/components/AdminEffortTab.vue
Normal file
96
frontend/components/AdminEffortTab.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<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>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th 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="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="2" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun effort trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
|
||||
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>
|
||||
103
frontend/components/AdminPriorityTab.vue
Normal file
103
frontend/components/AdminPriorityTab.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<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>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th 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="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucune priorité trouvée.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
|
||||
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>
|
||||
105
frontend/components/AdminStatusTab.vue
Normal file
105
frontend/components/AdminStatusTab.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<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>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Position</th>
|
||||
<th 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="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-700">{{ item.position }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="4" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun statut trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const { getAll, remove } = useTaskStatusService()
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | 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: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
103
frontend/components/AdminTypeTab.vue
Normal file
103
frontend/components/AdminTypeTab.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Types</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 type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th 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="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun type trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskTypeDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskType } from '~/services/dto/task-type'
|
||||
import { useTaskTypeService } from '~/services/task-types'
|
||||
|
||||
const { getAll, remove } = useTaskTypeService()
|
||||
const items = ref<TaskType[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskType | 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: TaskType) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
106
frontend/components/AdminUserTab.vue
Normal file
106
frontend/components/AdminUserTab.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<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>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Nom d'utilisateur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Rôles</th>
|
||||
<th 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="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun utilisateur trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
|
||||
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>
|
||||
68
frontend/components/AppDrawer.vue
Normal file
68
frontend/components/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>
|
||||
137
frontend/components/ClientDrawer.vue
Normal file
137
frontend/components/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>
|
||||
31
frontend/components/ColorPicker.vue
Normal file
31
frontend/components/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>
|
||||
123
frontend/components/ProjectDrawer.vue
Normal file
123
frontend/components/ProjectDrawer.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<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.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({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
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.name = props.project.name ?? ''
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
} else {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
}
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.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 {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
72
frontend/components/TaskCard.vue
Normal file
72
frontend/components/TaskCard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<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">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<button
|
||||
class="shrink-0 text-neutral-400 hover:text-primary-500"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="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="type in task.types"
|
||||
:key="type.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: type.color }"
|
||||
>
|
||||
{{ type.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
|
||||
}>()
|
||||
|
||||
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>
|
||||
225
frontend/components/TaskDrawer.vue
Normal file
225
frontend/components/TaskDrawer.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<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">Types</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="type in types"
|
||||
:key="type.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.typeIds.includes(type.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="type.id"
|
||||
:checked="form.typeIds.includes(type.id)"
|
||||
@change="toggleType(type.id)"
|
||||
/>
|
||||
{{ type.label }}
|
||||
</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 { 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 { TaskType } from '~/services/dto/task-type'
|
||||
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[]
|
||||
types: TaskType[]
|
||||
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 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,
|
||||
typeIds: [] 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 }))
|
||||
)
|
||||
|
||||
function toggleType(id: number) {
|
||||
const idx = form.typeIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.typeIds.splice(idx, 1)
|
||||
} else {
|
||||
form.typeIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.task) {
|
||||
form.title = props.task.title ?? ''
|
||||
form.description = props.task.description ?? ''
|
||||
form.statusId = props.task.status?.id ?? null
|
||||
form.effortId = props.task.effort?.id ?? null
|
||||
form.priorityId = props.task.priority?.id ?? null
|
||||
form.assigneeId = props.task.assignee?.id ?? null
|
||||
form.groupId = props.task.group?.id ?? null
|
||||
form.typeIds = props.task.types.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.typeIds = []
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskService()
|
||||
|
||||
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}`,
|
||||
types: form.typeIds.map(id => `/api/task_types/${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/TaskEffortDrawer.vue
Normal file
90
frontend/components/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/TaskGroupDrawer.vue
Normal file
108
frontend/components/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/TaskPriorityDrawer.vue
Normal file
97
frontend/components/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>
|
||||
107
frontend/components/TaskStatusDrawer.vue
Normal file
107
frontend/components/TaskStatusDrawer.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<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-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',
|
||||
})
|
||||
|
||||
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'
|
||||
} else {
|
||||
form.label = ''
|
||||
form.position = '0'
|
||||
form.color = '#222783'
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
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/TaskTypeDrawer.vue
Normal file
97
frontend/components/TaskTypeDrawer.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un type' : 'Ajouter un type'">
|
||||
<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 { TaskType, TaskTypeWrite } from '~/services/dto/task-type'
|
||||
import { useTaskTypeService } from '~/services/task-types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskType | 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 } = useTaskTypeService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskTypeWrite = {
|
||||
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>
|
||||
133
frontend/components/UserDrawer.vue
Normal file
133
frontend/components/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 => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase ?? '/api'
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
@@ -18,5 +18,50 @@
|
||||
"login": "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."
|
||||
},
|
||||
"taskTypes": {
|
||||
"created": "Type créé avec succès.",
|
||||
"updated": "Type mis à jour avec succès.",
|
||||
"deleted": "Type supprimé avec succès."
|
||||
},
|
||||
"taskGroups": {
|
||||
"created": "Groupe créé avec succès.",
|
||||
"updated": "Groupe mis à jour avec succès.",
|
||||
"deleted": "Groupe supprimé avec succès."
|
||||
},
|
||||
"tasks": {
|
||||
"created": "Ticket créé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
"deleted": "Ticket supprimé avec succès."
|
||||
},
|
||||
"users": {
|
||||
"created": "Utilisateur créé avec succès.",
|
||||
"updated": "Utilisateur mis à jour avec succès.",
|
||||
"deleted": "Utilisateur supprimé avec succès."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,29 @@
|
||||
<span class="self-baseline text-md">Tableau de bord</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/project-list"
|
||||
to="/projects"
|
||||
class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
<Icon name="mdi:folder-outline" size="24"/>
|
||||
<span class="self-baseline text-md">Projets</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/clients"
|
||||
class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
<Icon name="mdi:account-group-outline" size="24"/>
|
||||
<span class="self-baseline text-md">Clients</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/admin"
|
||||
class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
<Icon name="mdi:cog-outline" size="24"/>
|
||||
<span class="self-baseline text-md">Administration</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
45
frontend/pages/admin.vue
Normal file
45
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900">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">
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTypeTab v-if="activeTab === 'types'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const tabs = [
|
||||
{ key: 'statuses', label: 'Statuts' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'types', label: 'Types' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
const activeTab = ref<TabKey>('statuses')
|
||||
</script>
|
||||
110
frontend/pages/clients.vue
Normal file
110
frontend/pages/clients.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">Clients</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 client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 class="px-4 py-3 font-semibold text-neutral-700">Nom</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Email</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Adresse</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Téléphone</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(client)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ client.name }}</td>
|
||||
<td class="px-4 py-3 text-primary-500">{{ client.email ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-neutral-700">{{ formatAddress(client) }}</td>
|
||||
<td class="px-4 py-3 text-primary-500">{{ client.phone ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(client.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="clients.length === 0 && !isLoading">
|
||||
<td colspan="4" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun client trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
|
||||
useHead({ title: 'Clients' })
|
||||
|
||||
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>
|
||||
308
frontend/pages/projects/[id].vue
Normal file
308
frontend/pages/projects/[id].vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="rounded-md bg-secondary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-600"
|
||||
@click="openGroupCreate"
|
||||
>
|
||||
+ Ajouter un groupe
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<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="type in task.types"
|
||||
:key="type.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: type.color }"
|
||||
>
|
||||
{{ type.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"
|
||||
:types="types"
|
||||
:groups="groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<TaskGroupDrawer
|
||||
v-model="groupDrawerOpen"
|
||||
:group="selectedGroup"
|
||||
:project-id="projectId"
|
||||
@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 { TaskType } from '~/services/dto/task-type'
|
||||
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 { useTaskTypeService } from '~/services/task-types'
|
||||
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 typeService = useTaskTypeService()
|
||||
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 types = ref<TaskType[]>([])
|
||||
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 groupDrawerOpen = ref(false)
|
||||
const selectedGroup = ref<TaskGroup | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
groups.value.map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!selectedGroupId.value) return tasks.value
|
||||
return tasks.value.filter(t => t.group?.id === selectedGroupId.value)
|
||||
})
|
||||
|
||||
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(),
|
||||
typeService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
types.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 openGroupCreate() {
|
||||
selectedGroup.value = null
|
||||
groupDrawerOpen.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>
|
||||
102
frontend/pages/projects/index.vue
Normal file
102
frontend/pages/projects/index.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">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">
|
||||
<div class="size-8 shrink-0 rounded-full" :style="{ backgroundColor: project.color }" />
|
||||
<h3 class="text-md font-bold text-primary-500">{{ 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>
|
||||
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
|
||||
}
|
||||
17
frontend/services/dto/project.ts
Normal file
17
frontend/services/dto/project.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Client } from './client'
|
||||
|
||||
export type Project = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
client: Client | null
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
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
|
||||
}
|
||||
17
frontend/services/dto/task-group.ts
Normal file
17
frontend/services/dto/task-group.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Project } from './project'
|
||||
|
||||
export type TaskGroup = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: Project | null
|
||||
}
|
||||
|
||||
export type TaskGroupWrite = {
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: string
|
||||
}
|
||||
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
|
||||
}
|
||||
13
frontend/services/dto/task-status.ts
Normal file
13
frontend/services/dto/task-status.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
11
frontend/services/dto/task-type.ts
Normal file
11
frontend/services/dto/task-type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type TaskType = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TaskTypeWrite = {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
31
frontend/services/dto/task.ts
Normal file
31
frontend/services/dto/task.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { TaskStatus } from './task-status'
|
||||
import type { TaskEffort } from './task-effort'
|
||||
import type { TaskPriority } from './task-priority'
|
||||
import type { TaskType } from './task-type'
|
||||
import type { TaskGroup } from './task-group'
|
||||
import type { UserData } from './user-data'
|
||||
|
||||
export type Task = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus | null
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
group: TaskGroup | null
|
||||
types: TaskType[]
|
||||
}
|
||||
|
||||
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
|
||||
types: string[]
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
export type UserData = {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
id: number
|
||||
'@id'?: 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-types.ts
Normal file
32
frontend/services/task-types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { TaskType, TaskTypeWrite } from './dto/task-type'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskTypeService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskType[]> {
|
||||
const data = await api.get<HydraCollection<TaskType>>('/task_types')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskTypeWrite): Promise<TaskType> {
|
||||
return api.post<TaskType>('/task_types', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskTypes.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskTypeWrite>): Promise<TaskType> {
|
||||
return api.patch<TaskType>(`/task_types/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskTypes.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_types/${id}`, {}, {
|
||||
toastSuccessKey: 'taskTypes.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
34
frontend/services/tasks.ts
Normal file
34
frontend/services/tasks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 getByProject(projectId: number): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
||||
project: `/api/projects/${projectId}`,
|
||||
})
|
||||
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 { getByProject, 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 }
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
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\TaskType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
@@ -17,12 +25,231 @@ class AppFixtures extends Fixture
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// User admin
|
||||
$admin = new User();
|
||||
$admin->setUsername('admin');
|
||||
$admin->setRoles(['ROLE_ADMIN']);
|
||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, '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->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->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->setName('ERP');
|
||||
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||
$projectErp->setColor('#4A90D9');
|
||||
$projectErp->setClient($clientNova);
|
||||
$manager->persist($projectErp);
|
||||
|
||||
$projectInterne = new Project();
|
||||
$projectInterne->setName('Site vitrine');
|
||||
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||
$projectInterne->setColor('#26A69A');
|
||||
$projectInterne->setClient(null);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Task Statuses
|
||||
$statusTodo = new TaskStatus();
|
||||
$statusTodo->setLabel('A faire');
|
||||
$statusTodo->setColor('#222783');
|
||||
$statusTodo->setPosition(0);
|
||||
$manager->persist($statusTodo);
|
||||
|
||||
$statusInProgress = new TaskStatus();
|
||||
$statusInProgress->setLabel('En cours');
|
||||
$statusInProgress->setColor('#222783');
|
||||
$statusInProgress->setPosition(1);
|
||||
$manager->persist($statusInProgress);
|
||||
|
||||
$statusBlocked = new TaskStatus();
|
||||
$statusBlocked->setLabel('Bloqué');
|
||||
$statusBlocked->setColor('#222783');
|
||||
$statusBlocked->setPosition(2);
|
||||
$manager->persist($statusBlocked);
|
||||
|
||||
$statusReview = new TaskStatus();
|
||||
$statusReview->setLabel('En attente de validation');
|
||||
$statusReview->setColor('#222783');
|
||||
$statusReview->setPosition(3);
|
||||
$manager->persist($statusReview);
|
||||
|
||||
$statusDone = new TaskStatus();
|
||||
$statusDone->setLabel('Terminé');
|
||||
$statusDone->setColor('#222783');
|
||||
$statusDone->setPosition(4);
|
||||
$manager->persist($statusDone);
|
||||
|
||||
// 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 Types
|
||||
$typePassword = new TaskType();
|
||||
$typePassword->setLabel('Gestion mdp');
|
||||
$typePassword->setColor('#C62828');
|
||||
$manager->persist($typePassword);
|
||||
|
||||
$typeAuth = new TaskType();
|
||||
$typeAuth->setLabel('Connexion');
|
||||
$typeAuth->setColor('#FF8F00');
|
||||
$manager->persist($typeAuth);
|
||||
|
||||
$typeCalendar = new TaskType();
|
||||
$typeCalendar->setLabel('Calendrier');
|
||||
$typeCalendar->setColor('#222783');
|
||||
$manager->persist($typeCalendar);
|
||||
|
||||
// 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->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->addType($typePassword);
|
||||
$manager->persist($task1);
|
||||
|
||||
$task2 = new Task();
|
||||
$task2->setTitle('Création d\'une page de login');
|
||||
$task2->setStatus($statusTodo);
|
||||
$task2->setEffort($effortL);
|
||||
$task2->setPriority($priorityHigh);
|
||||
$task2->setAssignee($admin);
|
||||
$task2->setGroup($groupFrontend);
|
||||
$task2->setProject($projectSirh);
|
||||
$task2->addType($typeAuth);
|
||||
$manager->persist($task2);
|
||||
|
||||
$task3 = new Task();
|
||||
$task3->setTitle('Création d\'une page de login');
|
||||
$task3->setStatus($statusInProgress);
|
||||
$task3->setEffort($effortXXL);
|
||||
$task3->setPriority($priorityLow);
|
||||
$task3->setAssignee($admin);
|
||||
$task3->setGroup($groupBackend);
|
||||
$task3->setProject($projectSirh);
|
||||
$task3->addType($typePassword);
|
||||
$manager->persist($task3);
|
||||
|
||||
$task4 = new Task();
|
||||
$task4->setTitle('Création d\'une page de login');
|
||||
$task4->setStatus($statusBlocked);
|
||||
$task4->setEffort($effortXXL);
|
||||
$task4->setPriority($priorityLow);
|
||||
$task4->setAssignee($admin);
|
||||
$task4->setProject($projectSirh);
|
||||
$task4->addType($typePassword);
|
||||
$manager->persist($task4);
|
||||
|
||||
$task5 = new Task();
|
||||
$task5->setTitle('Création d\'une page de login');
|
||||
$task5->setStatus($statusReview);
|
||||
$task5->setEffort($effortXXL);
|
||||
$task5->setPriority($priorityMedium);
|
||||
$task5->setAssignee($admin);
|
||||
$task5->setProject($projectSirh);
|
||||
$task5->addType($typeCalendar);
|
||||
$manager->persist($task5);
|
||||
|
||||
$task6 = new Task();
|
||||
$task6->setTitle('Création d\'une page de login');
|
||||
$task6->setStatus($statusDone);
|
||||
$task6->setEffort($effortXXL);
|
||||
$task6->setPriority($priorityHigh);
|
||||
$task6->setAssignee($admin);
|
||||
$task6->setProject($projectSirh);
|
||||
$task6->addType($typeAuth);
|
||||
$manager->persist($task6);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
107
src/Entity/Project.php
Normal file
107
src/Entity/Project.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?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\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' => ['project:read']],
|
||||
denormalizationContext: ['groups' => ['project:write']],
|
||||
order: ['name' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||
class Project
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['project:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
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'])]
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
214
src/Entity/Task.php
Normal file
214
src/Entity/Task.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
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 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' => ['task:read']],
|
||||
denormalizationContext: ['groups' => ['task:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||
class Task
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task:read'])]
|
||||
private ?int $id = 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, TaskType> */
|
||||
#[ORM\ManyToMany(targetEntity: TaskType::class)]
|
||||
#[ORM\JoinTable(name: 'task_task_type')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private Collection $types;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->types = 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 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, TaskType> */
|
||||
public function getTypes(): Collection
|
||||
{
|
||||
return $this->types;
|
||||
}
|
||||
|
||||
public function addType(TaskType $type): static
|
||||
{
|
||||
if (!$this->types->contains($type)) {
|
||||
$this->types->add($type);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeType(TaskType $type): static
|
||||
{
|
||||
$this->types->removeElement($type);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
110
src/Entity/TaskGroup.php
Normal file
110
src/Entity/TaskGroup.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
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'])]
|
||||
#[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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
90
src/Entity/TaskStatus.php
Normal file
90
src/Entity/TaskStatus.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_type:read', 'task_type:write', 'task:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['task_type:read', 'task_type: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;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,14 @@ 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\UserRepository;
|
||||
use App\State\MeProvider;
|
||||
use App\State\UserPasswordHasherProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -22,7 +27,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
provider: MeProvider::class,
|
||||
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\Table(name: '`user`')]
|
||||
@@ -31,19 +46,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read'])]
|
||||
#[Groups(['me:read', 'task:read', 'user:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180, unique: true)]
|
||||
#[Groups(['me:read'])]
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'user:write'])]
|
||||
private ?string $username = null;
|
||||
|
||||
/** @var list<string> */
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read'])]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private array $roles = [];
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:write'])]
|
||||
private ?string $password = null;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
17
src/Repository/TaskRepository.php
Normal file
17
src/Repository/TaskRepository.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
17
src/Repository/TaskTypeRepository.php
Normal file
17
src/Repository/TaskTypeRepository.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TaskType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class TaskTypeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskType::class);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ final readonly class MeProvider implements ProviderInterface
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
|
||||
{
|
||||
/** @var User $user */
|
||||
// @var User $user
|
||||
return $this->security->getUser();
|
||||
}
|
||||
}
|
||||
|
||||
40
src/State/UserPasswordHasherProcessor.php
Normal file
40
src/State/UserPasswordHasherProcessor.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final readonly class UserPasswordHasherProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @param ProcessorInterface<User, User> $persistProcessor
|
||||
*/
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param User $data
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (null !== $data->getPassword() && !str_starts_with($data->getPassword(), '$')) {
|
||||
$data->setPassword(
|
||||
$this->passwordHasher->hashPassword($data, $data->getPassword())
|
||||
);
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user