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