Files
Lesstime/docs/superpowers/plans/2026-03-12-task-archiving.md
Matthieu 73d0c7b4fa docs : add task archiving implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:43:21 +01:00

1181 lines
32 KiB
Markdown

# Task Archiving 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:** Allow archiving individual tasks (when status is final) and entire groups (when all tasks have final status), with a dedicated archives page per project and a delete confirmation modal.
**Architecture:** Add `isFinal` boolean on TaskStatus, `archived` boolean on Task and TaskGroup. Frontend filters archived items from kanban, shows them in a new `/projects/[id]/archives` page. Group archiving is handled via sequential PATCH calls from frontend.
**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / Pinia / Tailwind CSS (frontend)
---
## Chunk 1: Backend — Schema & API changes
### Task 1: Add `isFinal` to TaskStatus entity
**Files:**
- Modify: `src/Entity/TaskStatus.php:46-48`
- [ ] **Step 1: Add `isFinal` property with ORM mapping and serialization groups**
Add after the `$position` property (line 48):
```php
#[ORM\Column(type: 'boolean')]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
private bool $isFinal = false;
```
- [ ] **Step 2: Add getter and setter**
Add after `setPosition()` (line 89):
```php
public function isFinal(): bool
{
return $this->isFinal;
}
public function setIsFinal(bool $isFinal): static
{
$this->isFinal = $isFinal;
return $this;
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/TaskStatus.php
git commit -m "feat(backend) : add isFinal field to TaskStatus entity"
```
### Task 2: Add `archived` to Task entity
**Files:**
- Modify: `src/Entity/Task.php:7-8,34,84`
- [ ] **Step 1: Add BooleanFilter import and filter attribute**
Add import at top of file:
```php
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
```
Add a second `#[ApiFilter]` line after the existing SearchFilter (line 34):
```php
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
```
- [ ] **Step 2: Add `archived` property with ORM mapping and serialization groups**
Add after the `$tags` property (line 94):
```php
#[ORM\Column(type: 'boolean')]
#[Groups(['task:read', 'task:write'])]
private bool $archived = false;
```
- [ ] **Step 3: Add getter and setter**
Add after `removeTag()` (line 233):
```php
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Task.php
git commit -m "feat(backend) : add archived field to Task entity"
```
### Task 3: Add `archived` to TaskGroup entity
**Files:**
- Modify: `src/Entity/TaskGroup.php:7-8,31,56`
- [ ] **Step 1: Add BooleanFilter import and filter attribute**
Add import at top of file:
```php
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
```
Add a second `#[ApiFilter]` line after the existing SearchFilter (line 31):
```php
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
```
- [ ] **Step 2: Add `archived` property with ORM mapping and serialization groups**
Add after `$project` property (line 56):
```php
#[ORM\Column(type: 'boolean')]
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
private bool $archived = false;
```
- [ ] **Step 3: Add getter and setter**
Add after `setProject()` (line 108):
```php
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Entity/TaskGroup.php
git commit -m "feat(backend) : add archived field to TaskGroup entity"
```
### Task 4: Generate and run migration
**Files:**
- Create: `migrations/VersionXXXXXXXXXXXXXX.php` (auto-generated)
- [ ] **Step 1: Generate migration**
```bash
make shell
# Inside container:
php bin/console doctrine:migrations:diff
exit
```
- [ ] **Step 2: Run migration**
```bash
make migration-migrate
```
- [ ] **Step 3: Commit**
```bash
git add migrations/
git commit -m "feat(backend) : add migration for isFinal, archived fields"
```
### Task 5: Update fixtures — set `isFinal` on "Terminé"
**Files:**
- Modify: `src/DataFixtures/AppFixtures.php:110-115`
- [ ] **Step 1: Add `setIsFinal(true)` on "Terminé" status**
In the fixture loop (line 109-116), add after the `$status` creation block, right before `$manager->persist($status)`:
```php
$statusObjects = [];
foreach ($defaultStatuses as [$label, $color, $position]) {
$status = new TaskStatus();
$status->setLabel($label);
$status->setColor($color);
$status->setPosition($position);
if ($label === 'Terminé') {
$status->setIsFinal(true);
}
$manager->persist($status);
$statusObjects[$label] = $status;
}
```
- [ ] **Step 2: Reload fixtures to verify**
```bash
make fixtures
```
- [ ] **Step 3: Commit**
```bash
git add src/DataFixtures/AppFixtures.php
git commit -m "feat(backend) : set isFinal on Terminé status in fixtures"
```
---
## Chunk 2: Frontend — DTOs, services, and i18n
### Task 6: Update DTOs
**Files:**
- Modify: `frontend/services/dto/task-status.ts`
- Modify: `frontend/services/dto/task.ts`
- Modify: `frontend/services/dto/task-group.ts`
- [ ] **Step 1: Add `isFinal` to TaskStatus DTO**
In `frontend/services/dto/task-status.ts`, add `isFinal` to both types:
```typescript
export type TaskStatus = {
id: number
'@id'?: string
label: string
color: string
position: number
isFinal: boolean
}
export type TaskStatusWrite = {
label: string
color: string
position: number
isFinal: boolean
}
```
- [ ] **Step 2: Add `archived` to Task DTO**
In `frontend/services/dto/task.ts`, add `archived` to both types:
```typescript
// In Task type, add after tags:
archived: boolean
// In TaskWrite type, add after tags:
archived?: boolean
```
- [ ] **Step 3: Add `archived` to TaskGroup DTO**
In `frontend/services/dto/task-group.ts`, add `archived` to both types:
```typescript
// In TaskGroup type, add after project:
archived: boolean
// In TaskGroupWrite type, add after project:
archived?: boolean
```
- [ ] **Step 4: Commit**
```bash
git add frontend/services/dto/task-status.ts frontend/services/dto/task.ts frontend/services/dto/task-group.ts
git commit -m "feat(frontend) : add isFinal and archived fields to DTOs"
```
### Task 7: Update task service — add `getByProjectArchived`
**Files:**
- Modify: `frontend/services/tasks.ts`
- [ ] **Step 1: Add method to fetch archived tasks**
Add after `getByProject` (line 18):
```typescript
async function getByProjectArchived(projectId: number): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: true,
})
return extractHydraMembers(data)
}
```
- [ ] **Step 2: Update `getByProject` to filter non-archived only**
Update the existing `getByProject` to explicitly pass `archived: false`:
```typescript
async function getByProject(projectId: number): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: false,
})
return extractHydraMembers(data)
}
```
- [ ] **Step 3: Export the new method**
Update the return statement (line 38):
```typescript
return { getAll, getByProject, getByProjectArchived, create, update, remove }
```
- [ ] **Step 4: Commit**
```bash
git add frontend/services/tasks.ts
git commit -m "feat(frontend) : add getByProjectArchived to task service"
```
### Task 8: Update i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add archiving translation keys**
Add the following keys to `fr.json`:
```json
"tasks": {
"created": "Ticket créé avec succès.",
"updated": "Ticket mis à jour avec succès.",
"deleted": "Ticket supprimé avec succès.",
"archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
},
"taskGroups": {
"created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès."
}
```
Also add:
```json
"archive": {
"title": "Archives",
"empty": "Aucun ticket archivé.",
"archiveButton": "Archiver",
"unarchiveButton": "Désarchiver",
"showArchived": "Voir les groupes archivés",
"hideArchived": "Masquer les groupes archivés",
"statusFinal": "Statut final",
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe."
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add archiving i18n translations"
```
---
## Chunk 3: Frontend — TaskDrawer (archive button + delete confirmation modal)
### Task 9: Create ConfirmDeleteTaskModal component
**Files:**
- Create: `frontend/components/ui/ConfirmDeleteTaskModal.vue`
- [ ] **Step 1: Create the modal component**
Follow the pattern of `ConfirmDeleteStatusModal.vue`:
```vue
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('tasks.deleteConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('tasks.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
Annuler
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
Supprimer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/ui/ConfirmDeleteTaskModal.vue
git commit -m "feat(frontend) : create ConfirmDeleteTaskModal component"
```
### Task 10: Update TaskDrawer — archive button + delete confirmation
**Files:**
- Modify: `frontend/components/task/TaskDrawer.vue`
- [ ] **Step 1: Add archive/unarchive button to template**
Replace the button area (lines 76-93) with:
```vue
<div class="mt-6 flex flex-col gap-3">
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
v-if="isEditing"
type="button"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
Supprimer
</button>
<div class="flex gap-2">
<button
v-if="canArchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</div>
</div>
```
- [ ] **Step 2: Add ConfirmDeleteTaskModal to template**
Add right before the closing `</AppDrawer>` tag:
```vue
<ConfirmDeleteTaskModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
```
- [ ] **Step 3: Add computed properties and handlers in script**
Add after `const isSubmitting = ref(false)` (line 131):
```typescript
const confirmDeleteOpen = ref(false)
```
Add computed properties after `groupOptions` (line 166):
```typescript
const canArchive = computed(() => {
if (!isEditing.value || !props.task) return false
if (props.task.archived) return false
const status = props.statuses.find(s => s.id === props.task?.status?.id)
return !!status?.isFinal
})
const canUnarchive = computed(() => {
return isEditing.value && !!props.task?.archived
})
```
Add archive/unarchive handlers after `handleDelete` (line 224):
```typescript
async function handleArchive() {
if (!props.task) return
const timerStore = useTimerStore()
if (timerStore.activeEntry?.task && String(timerStore.activeEntry.task) === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
isSubmitting.value = true
try {
await update(props.task.id, { archived: true })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleUnarchive() {
if (!props.task) return
isSubmitting.value = true
try {
await update(props.task.id, { archived: false })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/components/task/TaskDrawer.vue
git commit -m "feat(frontend) : add archive/unarchive buttons and delete confirmation to TaskDrawer"
```
---
## Chunk 4: Frontend — Kanban filtering & Archives page
### Task 11: Filter archived tasks and groups from kanban
**Files:**
- Modify: `frontend/pages/projects/[id]/index.vue`
- [ ] **Step 1: Filter archived tasks from display**
Update `filteredTasks` computed (line 187-190) to also exclude archived tasks:
```typescript
const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) {
result = result.filter(t => t.group?.id === selectedGroupId.value)
}
return result
})
```
- [ ] **Step 2: Filter archived groups from group filter dropdown**
Update `groupFilterOptions` computed (line 183-185):
```typescript
const groupFilterOptions = computed(() =>
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
)
```
- [ ] **Step 3: Commit**
```bash
git add frontend/pages/projects/[id]/index.vue
git commit -m "feat(frontend) : filter archived tasks and groups from kanban view"
```
### Task 12: Create archives page
**Files:**
- Create: `frontend/pages/projects/[id]/archives.vue`
- [ ] **Step 1: Create the archives page**
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} {{ $t('archive.title') }}</h1>
</div>
<div class="mt-4">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="w-64"
/>
</div>
<div class="mt-6">
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
{{ $t('archive.empty') }}
</p>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="task in filteredTasks"
: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"
@click="openTaskEdit(task)"
>
<div class="flex items-center gap-3">
<span class="text-xs font-bold text-neutral-400">{{ project?.code }}-{{ task.number }}</span>
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="task.status"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.status.color }"
>
{{ task.status.label }}
</span>
<span
v-if="task.group"
class="rounded-full border px-2 py-0.5 text-xs font-semibold"
:style="{ borderColor: task.group.color, color: task.group.color }"
>
{{ task.group.title }}
</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>
</div>
</div>
</div>
</div>
<TaskDrawer
v-model="taskDrawerOpen"
:task="selectedTask"
:project-id="projectId"
:statuses="statuses"
:efforts="efforts"
:priorities="priorities"
:tags="tags"
:groups="groups"
:users="users"
@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 { TaskTag } from '~/services/dto/task-tag'
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 { useTaskTagService } from '~/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
const route = useRoute()
const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Archives' })
const projectService = useProjectService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
const groupService = useTaskGroupService()
const userService = useUserService()
const project = ref<Project | null>(null)
const archivedTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const selectedGroupId = ref<number | null>(null)
const taskDrawerOpen = ref(false)
const selectedTask = ref<Task | null>(null)
const groupFilterOptions = computed(() =>
groups.value.map(g => ({ label: g.title, value: g.id }))
)
const filteredTasks = computed(() => {
if (!selectedGroupId.value) return archivedTasks.value
return archivedTasks.value.filter(t => t.group?.id === selectedGroupId.value)
})
async function loadData() {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProjectArchived(projectId.value),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
groupService.getByProject(projectId.value),
userService.getAll(),
])
project.value = p
archivedTasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty
groups.value = g
users.value = u
}
function openTaskEdit(task: Task) {
selectedTask.value = task
taskDrawerOpen.value = true
}
async function onSaved() {
await loadData()
}
onMounted(() => {
loadData()
})
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/pages/projects/[id]/archives.vue
git commit -m "feat(frontend) : create project archives page"
```
### Task 13: Add Archives link to sidebar
**Files:**
- Modify: `frontend/layouts/default.vue:44-52`
- [ ] **Step 1: Add sidebar link for archives**
Add after the "Groupes" SidebarLink (line 51):
```vue
<SidebarLink
:to="`/projects/${currentProjectId}/archives`"
icon="mdi:archive-outline"
label="Archives"
:collapsed="ui.sidebarCollapsed"
sub
/>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/layouts/default.vue
git commit -m "feat(frontend) : add Archives sidebar link for projects"
```
---
## Chunk 5: Frontend — DataTable actions slot, Group archiving & Admin isFinal toggle
### Task 14: Add `actions` slot to DataTable component
**Files:**
- Modify: `frontend/components/ui/DataTable.vue`
- [ ] **Step 1: Add actions slot next to delete button**
In the template, update the actions `<th>` header (line 13-15) to show when either `deletable` or the `actions` slot is used:
```vue
<th v-if="deletable || $slots.actions" class="px-4 py-3 font-semibold text-neutral-700">
Actions
</th>
```
Update the actions `<td>` cell (line 35-42) to include both the slot and delete button:
```vue
<td v-if="deletable || $slots.actions" class="px-4 py-3">
<div class="flex items-center gap-2">
<slot name="actions" :item="item" />
<button
v-if="deletable"
class="text-[red-500] hover:text-[red-700]"
@click.stop="$emit('delete', item)"
>
<Icon name="mdi:delete-outline" size="20" />
</button>
</div>
</td>
```
Update the empty row colspan (line 46):
```vue
<td
:colspan="columns.length + (deletable || $slots.actions ? 1 : 0)"
class="px-4 py-8 text-center text-neutral-400"
>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/ui/DataTable.vue
git commit -m "feat(frontend) : add actions slot to DataTable component"
```
### Task 15: Update ProjectGroupTab — archive/unarchive groups
**Files:**
- Modify: `frontend/components/project/ProjectGroupTab.vue`
- [ ] **Step 1: Add task loading and archive toggle to script**
Replace the script section with:
```vue
<script setup lang="ts">
import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
projectId: number
}>()
const emit = defineEmits<{
(e: 'updated'): void
}>()
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'title', label: 'Titre', primary: true },
{ key: 'color', label: 'Couleur' },
{ key: 'description', label: 'Description', class: 'max-w-xs truncate text-neutral-700' },
]
const groupService = useTaskGroupService()
const taskService = useTaskService()
const allGroups = ref<TaskGroup[]>([])
const activeTasks = ref<Task[]>([])
const archivedTasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskGroup | null>(null)
const showArchived = ref(false)
const items = computed(() =>
allGroups.value.filter(g => showArchived.value ? g.archived : !g.archived)
)
function canArchiveGroup(group: TaskGroup): boolean {
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
if (groupTasks.length === 0) return false
return groupTasks.every(t => t.status?.isFinal === true)
}
async function loadItems() {
isLoading.value = true
try {
const [g, t, at] = await Promise.all([
groupService.getByProject(props.projectId),
taskService.getByProject(props.projectId),
taskService.getByProjectArchived(props.projectId),
])
allGroups.value = g
activeTasks.value = t
archivedTasks.value = at
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: TaskGroup) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await groupService.remove(id)
await loadItems()
emit('updated')
}
async function handleArchive(group: TaskGroup) {
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: true })))
await groupService.update(group.id, { archived: true })
await loadItems()
emit('updated')
}
async function handleUnarchive(group: TaskGroup) {
const groupTasks = archivedTasks.value.filter(t => t.group?.id === group.id)
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: false })))
await groupService.update(group.id, { archived: false })
await loadItems()
emit('updated')
}
async function onSaved() {
await loadItems()
emit('updated')
}
onMounted(() => {
loadItems()
})
</script>
```
- [ ] **Step 2: Update template with archive toggle and buttons**
Replace the full template with:
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
<div class="flex items-center gap-3">
<button
type="button"
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
@click="showArchived = !showArchived"
>
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
</button>
<button
v-if="!showArchived"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un groupe
</button>
</div>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun groupe trouvé."
:deletable="!showArchived"
@row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
>
<template #cell-color="{ item }">
<span
class="inline-block h-6 w-6 rounded-full"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #cell-description="{ item }">
{{ item.description ?? '—' }}
</template>
<template #actions="{ item }">
<button
v-if="!showArchived && canArchiveGroup(item)"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
@click.stop="handleArchive(item)"
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="showArchived"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
@click.stop="handleUnarchive(item)"
>
{{ $t('archive.unarchiveButton') }}
</button>
</template>
</DataTable>
<TaskGroupDrawer
v-model="drawerOpen"
:group="selectedItem"
:project-id="projectId"
@saved="onSaved"
/>
</div>
</template>
```
- [ ] **Step 3: Commit**
```bash
git add frontend/components/project/ProjectGroupTab.vue
git commit -m "feat(frontend) : add group archive/unarchive to ProjectGroupTab"
```
### Task 16: Add `isFinal` toggle to TaskStatusDrawer
**Files:**
- Modify: `frontend/components/task/TaskStatusDrawer.vue`
- [ ] **Step 1: Add checkbox to template**
Add after the ColorPicker div (line 19), before the submit button div:
```vue
<div class="mt-4 flex items-center gap-2">
<input
id="isFinal"
v-model="form.isFinal"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isFinal" class="text-sm font-medium text-neutral-700">
{{ $t('archive.statusFinal') }}
</label>
</div>
```
- [ ] **Step 2: Update form reactive and populate logic**
Add `isFinal` to the form reactive (line 56-60):
```typescript
const form = reactive({
label: '',
position: '0',
color: '#222783',
isFinal: false,
})
```
Update the watcher populate (line 66-79) to include `isFinal`:
```typescript
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.label = props.item.label ?? ''
form.position = String(props.item.position ?? 0)
form.color = props.item.color ?? '#222783'
form.isFinal = props.item.isFinal ?? false
} else {
form.label = ''
form.position = '0'
form.color = '#222783'
form.isFinal = false
}
touched.label = false
}
})
```
Update the payload (line 89-93):
```typescript
const payload: TaskStatusWrite = {
label: form.label.trim(),
position: Number(form.position),
color: form.color,
isFinal: form.isFinal,
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/components/task/TaskStatusDrawer.vue
git commit -m "feat(frontend) : add isFinal toggle to TaskStatusDrawer"
```
### Task 17: Verify everything works end-to-end
- [ ] **Step 1: Run the dev server**
```bash
make dev-nuxt
```
- [ ] **Step 2: Manual verification checklist**
1. Create/edit a status in admin → verify `isFinal` checkbox works
2. Set a task to "Terminé" status → verify "Archiver" button appears in TaskDrawer
3. Archive a task → verify it disappears from kanban
4. Go to Archives page → verify the archived task appears
5. Unarchive the task → verify it reappears in kanban
6. Delete button → verify confirmation modal appears
7. In Groups page → verify archive button shows when all group tasks are final
8. Archive a group → verify group and tasks disappear from kanban
9. Toggle "Voir les groupes archivés" → verify archived groups appear with unarchive button
- [ ] **Step 3: Final commit if any fixes needed**