feat(backend) : add project relation to TaskStatus entity with migration and fixtures

Add ManyToOne project field on TaskStatus, SearchFilter for API filtering,
migration to add the column, and update fixtures to create statuses per project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 21:58:41 +01:00
parent 50ae9ef549
commit 66bb94fc98
4 changed files with 83 additions and 336 deletions

View File

@@ -1,308 +0,0 @@
<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>

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260310201845 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE task_status ADD project_id INT NOT NULL');
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_40A9E1CF166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_40A9E1CF166D1F9C ON task_status (project_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_40A9E1CF166D1F9C');
$this->addSql('DROP INDEX IDX_40A9E1CF166D1F9C');
$this->addSql('ALTER TABLE task_status DROP project_id');
}
}

View File

@@ -89,36 +89,36 @@ class AppFixtures extends Fixture
$projectInterne->setClient(null);
$manager->persist($projectInterne);
// Task Statuses
$statusTodo = new TaskStatus();
$statusTodo->setLabel('A faire');
$statusTodo->setColor('#222783');
$statusTodo->setPosition(0);
$manager->persist($statusTodo);
// Task Statuses (per project)
$defaultStatuses = [
['A faire', '#222783', 0],
['En cours', '#4A90D9', 1],
['Bloqué', '#C62828', 2],
['En attente de validation', '#FF8F00', 3],
['Terminé', '#26A69A', 4],
];
$statusInProgress = new TaskStatus();
$statusInProgress->setLabel('En cours');
$statusInProgress->setColor('#222783');
$statusInProgress->setPosition(1);
$manager->persist($statusInProgress);
$statusesByProject = [];
foreach ([$projectSirh, $projectCrm, $projectErp, $projectInterne] as $proj) {
$projectStatuses = [];
foreach ($defaultStatuses as [$label, $color, $position]) {
$status = new TaskStatus();
$status->setLabel($label);
$status->setColor($color);
$status->setPosition($position);
$status->setProject($proj);
$manager->persist($status);
$projectStatuses[$label] = $status;
}
$statusesByProject[spl_object_id($proj)] = $projectStatuses;
}
$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);
$sirhStatuses = $statusesByProject[spl_object_id($projectSirh)];
$statusTodo = $sirhStatuses['A faire'];
$statusInProgress = $sirhStatuses['En cours'];
$statusBlocked = $sirhStatuses['Bloqué'];
$statusReview = $sirhStatuses['En attente de validation'];
$statusDone = $sirhStatuses['Terminé'];
// Task Efforts
$effortS = new TaskEffort();

View File

@@ -4,6 +4,8 @@ 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;
@@ -26,6 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['task_status:write']],
order: ['position' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact'])]
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
class TaskStatus
{
@@ -47,6 +50,11 @@ class TaskStatus
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
private ?int $position = 0;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_status:read', 'task_status:write'])]
private ?Project $project = null;
public function getId(): ?int
{
return $this->id;
@@ -87,4 +95,16 @@ class TaskStatus
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
}