feat : add project code and task auto-numbering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 08:20:31 +01:00
parent 56275a9ebe
commit 517511177c
5 changed files with 98 additions and 1 deletions

View File

@@ -1,6 +1,15 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"
label="Code"
input-class="w-full uppercase"
:disabled="isEditing"
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
@blur="touched.code = true"
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
/>
<MalioInputText
v-model="form.name"
label="Titre"
@@ -62,6 +71,7 @@ const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const form = reactive({
code: '',
name: '',
description: '',
color: '#222783',
@@ -69,6 +79,7 @@ const form = reactive({
})
const touched = reactive({
code: false,
name: false,
})
@@ -79,16 +90,19 @@ const clientOptions = computed(() =>
watch(() => props.modelValue, (open) => {
if (open) {
if (props.project) {
form.code = props.project.code ?? ''
form.name = props.project.name ?? ''
form.description = props.project.description ?? ''
form.color = props.project.color ?? '#222783'
form.clientId = props.project.client?.id ?? null
} else {
form.code = ''
form.name = ''
form.description = ''
form.color = '#222783'
form.clientId = null
}
touched.code = false
touched.name = false
}
})
@@ -97,7 +111,9 @@ const { create, update } = useProjectService()
async function handleSubmit() {
touched.name = true
touched.code = true
if (!form.name.trim()) return
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
isSubmitting.value = true
try {
@@ -111,6 +127,7 @@ async function handleSubmit() {
if (isEditing.value && props.project) {
await update(props.project.id, payload)
} else {
payload.code = form.code.trim()
await create(payload)
}

View File

@@ -3,6 +3,7 @@ import type { Client } from './client'
export type Project = {
id: number
'@id'?: string
code: string
name: string
description: string | null
color: string
@@ -10,6 +11,7 @@ export type Project = {
}
export type ProjectWrite = {
code?: string
name: string
description: string | null
color: string

View File

@@ -12,13 +12,18 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ProjectRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],
),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
@@ -27,6 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
class Project
{
#[ORM\Id]
@@ -35,6 +41,12 @@ class Project
#[Groups(['project:read', 'time_entry:read'])]
private ?int $id = null;
#[ORM\Column(length: 10, unique: true)]
#[Groups(['project:read', 'project:create', 'task:read'])]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[A-Z]{2,10}$/', message: 'Le code doit contenir entre 2 et 10 lettres majuscules.')]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
private ?string $name = null;
@@ -57,6 +69,18 @@ class Project
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -14,4 +15,17 @@ class TaskRepository extends ServiceEntityRepository
{
parent::__construct($registry, Task::class);
}
public function findMaxNumberByProject(Project $project): int
{
$result = $this->createQueryBuilder('t')
->select('MAX(t.number)')
->where('t.project = :project')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult()
;
return (int) ($result ?? 0);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Repository\TaskRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private TaskRepository $taskRepository,
) {}
/**
* @param Task $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
$maxNumber = $this->taskRepository->findMaxNumberByProject($data->getProject());
$data->setNumber($maxNumber + 1);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}