Files
Lesstime/docs/plans/2026-03-12-admin-clients-global-statuses.md
2026-03-13 12:06:58 +01:00

23 KiB

Admin Clients + Global Statuses Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Move clients management into the admin page as a tab, and make task statuses global (shared across all projects) instead of per-project.

Architecture: Two independent changes: (1) Extract client CRUD from its dedicated page into an AdminClientTab component inside the admin page, remove the standalone /clients page and sidebar link. (2) Remove the project relationship from TaskStatus entity, update the frontend to use getAll() everywhere instead of getByProject(), remove per-project status management pages/links, and update AdminStatusTab + TaskStatusDrawer to work without projectId.

Tech Stack: PHP 8.4 / Symfony 8 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / TypeScript (frontend)


Chunk 1: Move Clients into Admin

Task 1: Create AdminClientTab component

Files:

  • Create: frontend/components/admin/AdminClientTab.vue

  • Step 1: Create AdminClientTab.vue

Extract the logic from frontend/pages/clients.vue into a new admin tab component, following the same pattern as AdminPriorityTab.vue (h2 title instead of h1, no useHead).

<template>
    <div>
        <div class="flex items-center justify-between">
            <h2 class="text-lg font-bold text-neutral-900">Clients</h2>
            <button
                class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
                @click="openCreate"
            >
                + Ajouter un client
            </button>
        </div>

        <DataTable
            :columns="columns"
            :items="clients"
            :loading="isLoading"
            empty-message="Aucun client trouvé."
            deletable
            @row-click="openEdit"
            @delete="(item) => handleDelete(item.id)"
        >
            <template #cell-email="{ item }">
                {{ item.email ?? '-' }}
            </template>
            <template #cell-address="{ item }">
                {{ formatAddress(item) }}
            </template>
            <template #cell-phone="{ item }">
                {{ item.phone ?? '-' }}
            </template>
        </DataTable>

        <ClientDrawer
            v-model="drawerOpen"
            :client="selectedClient"
            @saved="onSaved"
        />
    </div>
</template>

<script setup lang="ts">
import type { Client } from '~/services/dto/client'
import { useClientService } from '~/services/clients'

import type { DataTableColumn } from '~/components/ui/DataTable.vue'

const columns: DataTableColumn[] = [
    { key: 'name', label: 'Nom', primary: true },
    { key: 'email', label: 'Email', class: 'text-primary-500' },
    { key: 'address', label: 'Adresse', class: 'text-neutral-700' },
    { key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
]

const { getAll, remove } = useClientService()
const clients = ref<Client[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)

async function loadClients() {
    isLoading.value = true
    try {
        clients.value = await getAll()
    } finally {
        isLoading.value = false
    }
}

function openCreate() {
    selectedClient.value = null
    drawerOpen.value = true
}

function openEdit(client: Client) {
    selectedClient.value = client
    drawerOpen.value = true
}

function formatAddress(client: Client): string {
    return [client.street, client.postalCode, client.city]
        .filter(Boolean)
        .join(', ') || '-'
}

async function handleDelete(id: number) {
    await remove(id)
    await loadClients()
}

async function onSaved() {
    await loadClients()
}

onMounted(() => {
    loadClients()
})
</script>
  • Step 2: Commit
git add frontend/components/admin/AdminClientTab.vue
git commit -m "feat(admin) : add AdminClientTab component"

Task 2: Add Clients tab to admin page and remove standalone page

Files:

  • Modify: frontend/pages/admin.vue

  • Delete: frontend/pages/clients.vue

  • Step 3: Update admin.vue to include Clients tab

Add AdminClientTab to the admin page. Add the tab entry to the tabs array and the corresponding v-if block:

In frontend/pages/admin.vue, update the tabs array:

const tabs = [
    { key: 'clients', label: 'Clients' },
    { key: 'efforts', label: 'Efforts' },
    { key: 'priorities', label: 'Priorités' },
    { key: 'types', label: 'Types' },
    { key: 'users', label: 'Utilisateurs' },
] as const

Change the default active tab:

const activeTab = ref<TabKey>('clients')

Add the component in the template <div class="mt-6"> block:

<AdminClientTab v-if="activeTab === 'clients'" />
  • Step 4: Delete standalone clients page

Delete frontend/pages/clients.vue.

  • Step 5: Commit
git add frontend/pages/admin.vue
git rm frontend/pages/clients.vue
git commit -m "feat(admin) : move clients into admin page, remove standalone page"

Files:

  • Modify: frontend/layouts/default.vue

  • Step 6: Remove the Clients SidebarLink from default.vue

Remove the following block from frontend/layouts/default.vue (lines 60-65):

                    <SidebarLink
                        to="/clients"
                        icon="mdi:account-group-outline"
                        label="Clients"
                        :collapsed="ui.sidebarCollapsed"
                    />
  • Step 7: Commit
git add frontend/layouts/default.vue
git commit -m "refactor(frontend) : remove clients sidebar link"

Chunk 2: Make Task Statuses Global

Task 4: Remove project relationship from TaskStatus entity

Files:

  • Modify: src/Entity/TaskStatus.php

  • Step 8: Update TaskStatus entity to remove project relationship

In src/Entity/TaskStatus.php:

  1. Remove the SearchFilter import and #[ApiFilter] attribute
  2. Remove the $project property, its #[ORM] annotations, and the getProject()/setProject() methods

The entity should become:

<?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;
    }
}
  • Step 9: Commit
git add src/Entity/TaskStatus.php
git commit -m "refactor(backend) : remove project relationship from TaskStatus entity"

Task 5: Generate and run Doctrine migration

  • Step 10: Generate the migration
make shell
# Inside container:
php bin/console doctrine:migrations:diff
exit

This should generate a migration that:

  • Drops the project_id foreign key from task_status table

  • Drops the project_id column from task_status table

  • Step 11: Review the migration

Read the generated migration file in migrations/ to verify it only drops the FK and column.

  • Step 12: Reset database (since structure changed significantly)
make db-reset
  • Step 13: Commit
git add migrations/
git commit -m "feat(backend) : add migration to remove project_id from task_status"

Task 6: Update fixtures for global statuses

Files:

  • Modify: src/DataFixtures/AppFixtures.php

  • Step 14: Update fixtures to create global statuses instead of per-project

In src/DataFixtures/AppFixtures.php, replace the entire per-project status block (lines 95-124, from // Task Statuses (per project) through $statusDone = $sirhStatuses['Terminé'];) with global creation:

        // Task Statuses (global)
        $defaultStatuses = [
            ['A faire', '#222783', 0],
            ['En cours', '#4A90D9', 1],
            ['Bloqué', '#C62828', 2],
            ['En attente de validation', '#FF8F00', 3],
            ['Terminé', '#26A69A', 4],
        ];

        $statusObjects = [];
        foreach ($defaultStatuses as [$label, $color, $position]) {
            $status = new TaskStatus();
            $status->setLabel($label);
            $status->setColor($color);
            $status->setPosition($position);
            $manager->persist($status);
            $statusObjects[$label] = $status;
        }

        $statusTodo       = $statusObjects['A faire'];
        $statusInProgress = $statusObjects['En cours'];
        $statusBlocked    = $statusObjects['Bloqué'];
        $statusReview     = $statusObjects['En attente de validation'];
        $statusDone       = $statusObjects['Terminé'];

This replaces the loop that created statuses per-project AND the $statusesByProject / $sirhStatuses extraction lines (95-124). The task variable references ($statusTodo, etc.) remain identical so downstream task creation is unchanged.

  • Step 15: Reload fixtures to verify
make db-reset
  • Step 16: Commit
git add src/DataFixtures/AppFixtures.php
git commit -m "fix(fixtures) : create global statuses instead of per-project"

Task 7: Update frontend DTO and service for global statuses

Files:

  • Modify: frontend/services/dto/task-status.ts

  • Modify: frontend/services/task-statuses.ts

  • Step 17: Update TaskStatus DTO to remove project field

In frontend/services/dto/task-status.ts, remove the project import and field from both types:

export type TaskStatus = {
    id: number
    '@id'?: string
    label: string
    color: string
    position: number
}

export type TaskStatusWrite = {
    label: string
    color: string
    position: number
}
  • Step 18: Remove getByProject from task-statuses service

In frontend/services/task-statuses.ts, remove the getByProject function and its return:

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 }
}
  • Step 19: Commit
git add frontend/services/dto/task-status.ts frontend/services/task-statuses.ts
git commit -m "refactor(frontend) : remove project from TaskStatus DTO and service"

Task 8: Update TaskStatusDrawer to remove projectId

Files:

  • Modify: frontend/components/task/TaskStatusDrawer.vue

  • Step 20: Remove projectId prop from TaskStatusDrawer

In frontend/components/task/TaskStatusDrawer.vue:

  1. Remove projectId from props:
const props = defineProps<{
    modelValue: boolean
    item: TaskStatus | null
}>()
  1. Remove the project field from the payload in handleSubmit:
        const payload: TaskStatusWrite = {
            label: form.label.trim(),
            position: Number(form.position),
            color: form.color,
        }
  • Step 21: Commit
git add frontend/components/task/TaskStatusDrawer.vue
git commit -m "refactor(frontend) : remove projectId from TaskStatusDrawer"

Task 9: Add getAll to task service and update AdminStatusTab

Files:

  • Modify: frontend/services/tasks.ts

  • Modify: frontend/components/admin/AdminStatusTab.vue

  • Step 22: Add getAll() method to task service

frontend/services/tasks.ts currently only has getByProject(). Add a getAll function (needed by AdminStatusTab to check all tasks across projects when deleting a status).

Add this function inside useTaskService(), before getByProject:

    async function getAll(): Promise<Task[]> {
        const data = await api.get<HydraCollection<Task>>('/tasks')
        return extractHydraMembers(data)
    }

Update the return statement to include it:

    return { getAll, getByProject, create, update, remove }
  • Step 23: Update AdminStatusTab to handle task reassignment on delete

The existing AdminStatusTab does a simple remove(id) which would leave tasks orphaned. Port the reassignment logic from ProjectStatusTab (which is being deleted). Since statuses are now global, we need to load ALL tasks (not per-project) to check for affected tasks.

Replace the full content of frontend/components/admin/AdminStatusTab.vue with:

<template>
    <div>
        <div class="flex items-center justify-between">
            <h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
            <button
                class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
                @click="openCreate"
            >
                + Ajouter un statut
            </button>
        </div>

        <DataTable
            :columns="columns"
            :items="items"
            :loading="isLoading"
            empty-message="Aucun statut trouvé."
            deletable
            @row-click="openEdit"
            @delete="requestDelete"
        >
            <template #cell-color="{ item }">
                <span
                    class="inline-block h-6 w-6 rounded-full"
                    :style="{ backgroundColor: item.color }"
                />
            </template>
        </DataTable>

        <TaskStatusDrawer
            v-model="drawerOpen"
            :item="selectedItem"
            @saved="onSaved"
        />

        <ConfirmDeleteStatusModal
            v-model="confirmModalOpen"
            :status-label="statusToDelete?.label ?? ''"
            :task-count="affectedTaskCount"
            :available-statuses="reassignTargets"
            @confirm="onConfirmDelete"
        />
    </div>
</template>

<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import type { Task } from '~/services/dto/task'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskService } from '~/services/tasks'

import type { DataTableColumn } from '~/components/ui/DataTable.vue'

const columns: DataTableColumn[] = [
    { key: 'label', label: 'Libellé', primary: true },
    { key: 'color', label: 'Couleur' },
    { key: 'position', label: 'Position', class: 'text-neutral-700' },
]

const statusService = useTaskStatusService()
const taskService = useTaskService()

const items = ref<TaskStatus[]>([])
const tasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)
const confirmModalOpen = ref(false)
const statusToDelete = ref<TaskStatus | null>(null)

const affectedTaskCount = computed(() => {
    if (!statusToDelete.value) return 0
    return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
})

const reassignTargets = computed(() => {
    if (!statusToDelete.value) return items.value
    return items.value.filter(s => s.id !== statusToDelete.value!.id)
})

async function loadItems() {
    isLoading.value = true
    try {
        const [statuses, allTasks] = await Promise.all([
            statusService.getAll(),
            taskService.getAll(),
        ])
        items.value = statuses
        tasks.value = allTasks
    } finally {
        isLoading.value = false
    }
}

function openCreate() {
    selectedItem.value = null
    drawerOpen.value = true
}

function openEdit(item: TaskStatus) {
    selectedItem.value = item
    drawerOpen.value = true
}

async function requestDelete(item: TaskStatus) {
    statusToDelete.value = item
    const count = tasks.value.filter(t => t.status?.id === item.id).length
    if (count === 0) {
        await statusService.remove(item.id)
        await loadItems()
    } else {
        confirmModalOpen.value = true
    }
}

async function onConfirmDelete(targetStatusId: number | null) {
    if (!statusToDelete.value) return

    const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
    const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null

    await Promise.all(
        affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
    )

    await statusService.remove(statusToDelete.value.id)
    confirmModalOpen.value = false
    statusToDelete.value = null
    await loadItems()
}

async function onSaved() {
    await loadItems()
}

onMounted(() => {
    loadItems()
})
</script>
  • Step 24: Commit
git add frontend/services/tasks.ts frontend/components/admin/AdminStatusTab.vue
git commit -m "feat(admin) : add task reassignment logic to AdminStatusTab"

Task 10: Add Statuts tab to admin page

Files:

  • Modify: frontend/pages/admin.vue

  • Step 24: Add Statuts tab to admin.vue

In frontend/pages/admin.vue, update the tabs array to include statuses:

const tabs = [
    { key: 'clients', label: 'Clients' },
    { key: 'statuses', label: 'Statuts' },
    { key: 'efforts', label: 'Efforts' },
    { key: 'priorities', label: 'Priorités' },
    { key: 'types', label: 'Types' },
    { key: 'users', label: 'Utilisateurs' },
] as const

Add the component in the template:

<AdminStatusTab v-if="activeTab === 'statuses'" />
  • Step 25: Commit
git add frontend/pages/admin.vue
git commit -m "feat(admin) : add statuts tab to admin page"

Task 11: Update kanban page to use global statuses

Files:

  • Modify: frontend/pages/projects/[id]/index.vue

  • Step 26: Change kanban to load global statuses

In frontend/pages/projects/[id]/index.vue, in the loadData function, change:

statusService.getByProject(projectId.value),

to:

statusService.getAll(),
  • Step 27: Commit
git add frontend/pages/projects/[id]/index.vue
git commit -m "refactor(frontend) : load global statuses in kanban page"

Files:

  • Delete: frontend/pages/projects/[id]/statuses.vue

  • Delete: frontend/components/project/ProjectStatusTab.vue

  • Modify: frontend/layouts/default.vue

  • Step 28: Delete per-project statuses page and ProjectStatusTab

git rm frontend/pages/projects/[id]/statuses.vue
git rm frontend/components/project/ProjectStatusTab.vue
  • Step 29: Remove statuses SidebarLink from default.vue

In frontend/layouts/default.vue, remove the statuses sidebar link block (inside the v-if="currentProjectId" template, lines 52-58):

                        <SidebarLink
                            :to="`/projects/${currentProjectId}/statuses`"
                            icon="mdi:list-status"
                            label="Statuts"
                            :collapsed="ui.sidebarCollapsed"
                            sub
                        />
  • Step 30: Commit
git add frontend/layouts/default.vue
git commit -m "refactor(frontend) : remove per-project statuses page and sidebar link"

Task 13: Verify and clean up

  • Step 31: Check for remaining references to getByProject in task-statuses

Search for any remaining getByProject calls on the status service and projectId references in status-related components:

cd /home/matthieu/dev_malio/Lesstime
grep -rn "getByProject\|projectId" frontend/ --include="*.vue" --include="*.ts" | grep -i status
grep -rn "ConfirmDeleteStatusModal\|ProjectStatusTab" frontend/ --include="*.vue" --include="*.ts"

Fix any remaining references found.

  • Step 32: Run the dev server and verify
make db-reset && make dev-nuxt

Verify:

  1. Admin page shows Clients tab with full CRUD (create, edit, delete)
  2. Admin page shows Statuts tab with global statuses CRUD
  3. Sidebar no longer shows "Clients" or per-project "Statuts" links
  4. Kanban board displays all global statuses as columns
  5. No errors in browser console
  • Step 33: Final commit if any cleanup was needed
git add -A
git commit -m "chore : clean up remaining references after global statuses refactor"