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"
Task 3: Remove clients sidebar link
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:
- Remove the
SearchFilterimport and#[ApiFilter]attribute - Remove the
$projectproperty, its#[ORM]annotations, and thegetProject()/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_idforeign key fromtask_statustable -
Drops the
project_idcolumn fromtask_statustable -
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:
- Remove
projectIdfrom props:
const props = defineProps<{
modelValue: boolean
item: TaskStatus | null
}>()
- Remove the
projectfield from the payload inhandleSubmit:
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"
Task 12: Remove per-project status pages, sidebar link, and orphaned components
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:
- Admin page shows Clients tab with full CRUD (create, edit, delete)
- Admin page shows Statuts tab with global statuses CRUD
- Sidebar no longer shows "Clients" or per-project "Statuts" links
- Kanban board displays all global statuses as columns
- 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"