1181 lines
32 KiB
Markdown
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**
|