Files
Lesstime/docs/superpowers/plans/2026-03-15-task-documents.md
matthieu c9993ef32d docs : add task documents implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:59:29 +01:00

36 KiB

Task Documents 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 users to attach documents to tasks with drag & drop / file selection, preview images and PDFs in a fullscreen modal, and download any file.

Architecture: New TaskDocument entity with API Platform CRUD (multipart POST via custom Processor, download via custom Provider). Doctrine EntityListener for file cleanup on delete/cascade. Frontend: 3 components (upload zone, document list, preview modal) integrated into TaskModal.vue.

Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS

Spec: docs/superpowers/specs/2026-03-15-task-documents-design.md


Chunk 1: Backend — Entity, Migration, Config

Task 1: PHP/Nginx upload limits

Files:

  • Modify: docker/php/config/php.ini

  • Modify: docker/nginx/conf.d/lesstime.conf

  • Step 1: Add upload limits to php.ini

Append to docker/php/config/php.ini:

[Upload]
upload_max_filesize = 50M
post_max_size = 55M
  • Step 2: Add client_max_body_size to Nginx

Add client_max_body_size 55m; inside the server block of docker/nginx/conf.d/lesstime.conf, after index index.html;:

client_max_body_size 55m;
  • Step 3: Restart containers to apply config
docker restart php-lesstime-fpm nginx-lesstime
  • Step 4: Commit
git add docker/php/config/php.ini docker/nginx/conf.d/lesstime.conf
git commit -m "feat(config) : set upload limits to 50MB for task documents"

Task 1b: Docker volume for uploads persistence

Files:

  • Modify: docker-compose.yml

  • Step 1: Add named volume for uploads

In docker-compose.yml, add a named volume uploads_data and mount it in the php service:

Under php.volumes, add:

      - uploads_data:/var/www/html/var/uploads

Under top-level volumes, add:

  uploads_data:
  • Step 2: Restart containers
docker compose down && docker compose up -d
  • Step 3: Commit
git add docker-compose.yml
git commit -m "feat(docker) : add named volume for document uploads persistence"

Task 2: TaskDocument entity

Files:

  • Create: src/Entity/TaskDocument.php

  • Step 1: Create the TaskDocument entity

<?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\Post;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use App\State\TaskDocumentProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false),
        new Get(),
        new Post(
            security: "is_granted('ROLE_ADMIN')",
            processor: TaskDocumentProcessor::class,
            deserialize: false,
        ),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_document:read']],
    denormalizationContext: ['groups' => ['task_document:write']],
    order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([\App\EventListener\TaskDocumentListener::class])]
class TaskDocument
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_document:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    #[Groups(['task_document:read', 'task_document:write'])]
    private ?Task $task = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_document:read', 'task:read'])]
    private ?string $originalName = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_document:read', 'task:read'])]
    private ?string $fileName = null;

    #[ORM\Column(length: 100)]
    #[Groups(['task_document:read', 'task:read'])]
    private ?string $mimeType = null;

    #[ORM\Column]
    #[Groups(['task_document:read', 'task:read'])]
    private ?int $size = null;

    #[ORM\Column(type: 'datetime_immutable')]
    #[Groups(['task_document:read', 'task:read'])]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task_document:read', 'task:read'])]
    private ?User $uploadedBy = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTask(): ?Task
    {
        return $this->task;
    }

    public function setTask(?Task $task): static
    {
        $this->task = $task;

        return $this;
    }

    public function getOriginalName(): ?string
    {
        return $this->originalName;
    }

    public function setOriginalName(string $originalName): static
    {
        $this->originalName = $originalName;

        return $this;
    }

    public function getFileName(): ?string
    {
        return $this->fileName;
    }

    public function setFileName(string $fileName): static
    {
        $this->fileName = $fileName;

        return $this;
    }

    public function getMimeType(): ?string
    {
        return $this->mimeType;
    }

    public function setMimeType(string $mimeType): static
    {
        $this->mimeType = $mimeType;

        return $this;
    }

    public function getSize(): ?int
    {
        return $this->size;
    }

    public function setSize(int $size): static
    {
        $this->size = $size;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getUploadedBy(): ?User
    {
        return $this->uploadedBy;
    }

    public function setUploadedBy(?User $uploadedBy): static
    {
        $this->uploadedBy = $uploadedBy;

        return $this;
    }
}
  • Step 2: Add documents relation to Task entity

In src/Entity/Task.php, add the documents OneToMany collection:

  1. Add import: use Doctrine\Common\Collections\Collection; (already present)
  2. Add property after $archived:
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
#[Groups(['task:read'])]
private Collection $documents;
  1. In constructor, add: $this->documents = new ArrayCollection();

  2. Add getter:

/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
    return $this->documents;
}
  • Step 3: Commit
git add src/Entity/TaskDocument.php src/Entity/Task.php
git commit -m "feat : add TaskDocument entity with Task relation"

Task 3: Generate and run migration

Files:

  • Create: migrations/VersionXXX.php (auto-generated)

  • Step 1: Generate migration

docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
  • Step 2: Review the generated migration

Read the file and verify it creates task_document table with correct columns, indexes, and foreign keys.

  • Step 3: Run migration
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Commit
git add migrations/
git commit -m "feat : add task_document migration"

Task 4: TaskDocumentListener (file cleanup on delete)

Files:

  • Create: src/EventListener/TaskDocumentListener.php

  • Step 1: Create the listener

<?php

declare(strict_types=1);

namespace App\EventListener;

use App\Entity\TaskDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;

class TaskDocumentListener
{
    public function __construct(
        private readonly string $uploadDir,
        private readonly LoggerInterface $logger,
    ) {}

    public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void
    {
        $filePath = $this->uploadDir . '/' . $document->getFileName();

        if (file_exists($filePath)) {
            if (!unlink($filePath)) {
                $this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]);
            }
        } else {
            $this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]);
        }
    }
}
  • Step 2: Register the service with uploadDir parameter

Create config/services_task_document.yaml or add to config/services.yaml:

# In config/services.yaml, add:
parameters:
    task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'

services:
    App\EventListener\TaskDocumentListener:
        arguments:
            $uploadDir: '%task_document_upload_dir%'

If config/services.yaml already has parameters: section, merge into it. If not, add the parameter block.

  • Step 3: Create upload directory
mkdir -p var/uploads/documents
  • Step 4: Commit
git add src/EventListener/TaskDocumentListener.php config/services.yaml
git commit -m "feat : add TaskDocumentListener for file cleanup on delete"

Task 5: TaskDocumentProcessor (upload handler)

Files:

  • Create: src/State/TaskDocumentProcessor.php

  • Step 1: Create the processor

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\TaskDocument;
use App\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;

/**
 * @implements ProcessorInterface<TaskDocument, TaskDocument>
 */
final readonly class TaskDocumentProcessor implements ProcessorInterface
{
    private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB

    public function __construct(
        private EntityManagerInterface $entityManager,
        private Security $security,
        private RequestStack $requestStack,
        private string $uploadDir,
    ) {}

    /**
     * @param TaskDocument $data
     */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
    {
        $request = $this->requestStack->getCurrentRequest();

        if (null === $request) {
            throw new BadRequestHttpException('No request available.');
        }

        $file = $request->files->get('file');

        if (null === $file || !$file->isValid()) {
            throw new BadRequestHttpException('No valid file uploaded.');
        }

        if ($file->getSize() > self::MAX_FILE_SIZE) {
            throw new BadRequestHttpException('File size exceeds 50 MB limit.');
        }

        $taskIri = $request->request->get('task');

        if (null === $taskIri || '' === $taskIri) {
            throw new BadRequestHttpException('Task IRI is required.');
        }

        // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
        $taskId = (int) basename((string) $taskIri);
        $task = $this->entityManager->getRepository(Task::class)->find($taskId);

        if (null === $task) {
            throw new BadRequestHttpException('Task not found.');
        }

        // Capture file metadata BEFORE move() — move invalidates the temp file
        $originalName = $file->getClientOriginalName();
        $extension = $file->getClientOriginalExtension() ?: 'bin';
        $mimeType = $file->getClientMimeType() ?? 'application/octet-stream';
        $fileSize = $file->getSize();
        $uuid = Uuid::v4()->toRfc4122();
        $fileName = $uuid . '.' . $extension;

        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0o775, true);
        }

        $file->move($this->uploadDir, $fileName);

        $document = new TaskDocument();
        $document->setTask($task);
        $document->setOriginalName($originalName);
        $document->setFileName($fileName);
        $document->setMimeType($mimeType);
        $document->setSize($fileSize);
        $document->setCreatedAt(new \DateTimeImmutable());
        $document->setUploadedBy($this->security->getUser());

        $this->entityManager->persist($document);
        $this->entityManager->flush();

        return $document;
    }
}
  • Step 2: Register uploadDir injection

In config/services.yaml, add:

App\State\TaskDocumentProcessor:
    arguments:
        $uploadDir: '%task_document_upload_dir%'
  • Step 3: Commit
git add src/State/TaskDocumentProcessor.php config/services.yaml
git commit -m "feat : add TaskDocumentProcessor for multipart file upload"

Task 6: TaskDocumentDownloadController (file download)

Files:

  • Create: src/Controller/TaskDocumentDownloadController.php
  • Modify: config/routes.yaml (or create config/routes/task_document.yaml)

Why a controller instead of a Provider: API Platform providers return resource objects that get serialized. A file download needs to return a BinaryFileResponse directly, which bypasses API Platform's serialization pipeline. A Symfony controller is the correct approach for binary file serving.

  • Step 1: Create the download controller
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class TaskDocumentDownloadController extends AbstractController
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly string $uploadDir,
    ) {}

    #[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
    #[IsGranted('ROLE_USER')]
    public function __invoke(int $id): BinaryFileResponse
    {
        $document = $this->entityManager->getRepository(TaskDocument::class)->find($id);

        if (null === $document) {
            throw new NotFoundHttpException('Document not found.');
        }

        $filePath = $this->uploadDir . '/' . $document->getFileName();

        if (!file_exists($filePath)) {
            throw new NotFoundHttpException('File not found on disk.');
        }

        $response = new BinaryFileResponse($filePath);
        $mimeType = $document->getMimeType() ?? 'application/octet-stream';

        // Inline for images and PDFs, attachment for everything else
        $disposition = str_starts_with($mimeType, 'image/') || $mimeType === 'application/pdf'
            ? ResponseHeaderBag::DISPOSITION_INLINE
            : ResponseHeaderBag::DISPOSITION_ATTACHMENT;

        $response->setContentDisposition($disposition, $document->getOriginalName());
        $response->headers->set('Content-Type', $mimeType);

        return $response;
    }
}
  • Step 2: Register uploadDir injection

In config/services.yaml, add:

App\Controller\TaskDocumentDownloadController:
    arguments:
        $uploadDir: '%task_document_upload_dir%'
  • Step 3: Commit
git add src/Controller/TaskDocumentDownloadController.php config/services.yaml
git commit -m "feat : add TaskDocumentDownloadController for file download"

Task 7: Verify backend API

  • Step 1: Clear cache and verify no errors
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
  • Step 2: Test upload via curl
curl -X POST http://localhost:8082/api/task_documents \
  -H "Cookie: BEARER=<jwt_token>" \
  -F "file=@/path/to/test-file.png" \
  -F "task=/api/tasks/1"

Verify response returns JSON with document metadata.

  • Step 3: Test download
curl -I http://localhost:8082/api/task_documents/1/download \
  -H "Cookie: BEARER=<jwt_token>"

Verify Content-Type and Content-Disposition headers.

  • Step 4: Test delete
curl -X DELETE http://localhost:8082/api/task_documents/1 \
  -H "Cookie: BEARER=<jwt_token>"

Verify document removed from DB and file deleted from disk.


Chunk 2: Frontend — Service, DTO, Components

Task 8: TypeScript DTO and service

Files:

  • Create: frontend/services/dto/task-document.ts

  • Create: frontend/services/task-documents.ts

  • Step 1: Create DTO

import type { UserData } from './user-data'

export type TaskDocument = {
    '@id'?: string
    id: number
    task: string
    originalName: string
    fileName: string
    mimeType: string
    size: number
    createdAt: string
    uploadedBy: UserData | null
}
  • Step 2: Add documents to Task DTO

In frontend/services/dto/task.ts, add import and field:

import type { TaskDocument } from './task-document'

Add to Task type:

documents: TaskDocument[]
  • Step 3: Create task-documents service
import type { TaskDocument } from './dto/task-document'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
import { $fetch } from 'ofetch'

export function useTaskDocumentService() {
    const api = useApi()
    const config = useRuntimeConfig()
    const baseURL = config.public.apiBase || '/api'

    async function getByTask(taskId: number): Promise<TaskDocument[]> {
        const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
            task: `/api/tasks/${taskId}`,
        })
        return extractHydraMembers(data)
    }

    async function upload(taskId: number, file: File, onProgress?: (percent: number) => void): Promise<TaskDocument> {
        const formData = new FormData()
        formData.append('file', file)
        formData.append('task', `/api/tasks/${taskId}`)

        return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
            method: 'POST',
            body: formData,
            credentials: 'include',
            // Do NOT set Content-Type — browser sets multipart boundary automatically
        })
    }

    async function remove(id: number): Promise<void> {
        await api.delete(`/task_documents/${id}`, {}, {
            toastSuccessKey: 'taskDocuments.deleted',
        })
    }

    function getDownloadUrl(id: number): string {
        return `${baseURL}/task_documents/${id}/download`
    }

    return { getByTask, upload, remove, getDownloadUrl }
}
  • Step 4: Commit
git add frontend/services/dto/task-document.ts frontend/services/task-documents.ts frontend/services/dto/task.ts
git commit -m "feat(frontend) : add TaskDocument DTO and service"

Task 9: i18n translations

Files:

  • Modify: frontend/i18n/locales/fr.json

  • Step 1: Add translation keys

Add after the "tasks" block in fr.json:

"taskDocuments": {
    "title": "Documents",
    "dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner",
    "uploaded": "Document ajouté avec succès.",
    "deleted": "Document supprimé avec succès.",
    "uploadError": "Erreur lors de l'upload du document.",
    "confirmDeleteTitle": "Supprimer le document",
    "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
    "download": "Télécharger",
    "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
}
  • Step 2: Commit
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add task documents i18n translations"

Task 10: TaskDocumentUpload component

Files:

  • Create: frontend/components/task/TaskDocumentUpload.vue

  • Step 1: Create the upload component

<template>
    <div
        class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
        :class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
        @dragover.prevent="isDragging = true"
        @dragleave.prevent="isDragging = false"
        @drop.prevent="handleDrop"
        @click="fileInput?.click()"
    >
        <input
            ref="fileInput"
            type="file"
            multiple
            class="hidden"
            @change="handleFileSelect"
        />

        <div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
            <Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
            <p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
        </div>

        <!-- Upload progress -->
        <div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
            <div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
                <div class="min-w-0 flex-1">
                    <p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
                    <div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
                        <div
                            class="h-full rounded-full transition-all"
                            :class="upload.error ? 'bg-red-500' : 'bg-blue-500'"
                            :style="{ width: `${upload.progress}%` }"
                        />
                    </div>
                </div>
                <Icon
                    v-if="upload.error"
                    name="heroicons:exclamation-circle"
                    class="h-5 w-5 shrink-0 text-red-500"
                />
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
const props = defineProps<{
    taskId: number
}>()

const emit = defineEmits<{
    uploaded: []
}>()

const { upload: uploadFile } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()

const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)

type UploadState = {
    name: string
    progress: number
    error: boolean
}

const uploads = ref<UploadState[]>([])

function handleDrop(event: DragEvent) {
    isDragging.value = false
    const files = event.dataTransfer?.files
    if (files?.length) {
        processFiles(Array.from(files))
    }
}

function handleFileSelect(event: Event) {
    const input = event.target as HTMLInputElement
    if (input.files?.length) {
        processFiles(Array.from(input.files))
        input.value = ''
    }
}

async function processFiles(files: File[]) {
    const maxSize = 50 * 1024 * 1024

    for (const file of files) {
        if (file.size > maxSize) {
            toast.error({
                title: 'Erreur',
                message: t('taskDocuments.maxSizeError'),
            })
            continue
        }

        const state: UploadState = reactive({
            name: file.name,
            progress: 0,
            error: false,
        })
        uploads.value.push(state)

        try {
            await uploadFile(props.taskId, file)
            state.progress = 100
            toast.success({
                title: 'Succès',
                message: t('taskDocuments.uploaded'),
            })
        } catch {
            state.error = true
            state.progress = 100
            toast.error({
                title: 'Erreur',
                message: t('taskDocuments.uploadError'),
            })
        }
    }

    // Clean up completed uploads after a delay
    setTimeout(() => {
        uploads.value = uploads.value.filter(u => u.error)
    }, 2000)

    emit('uploaded')
}
</script>
  • Step 2: Commit
git add frontend/components/task/TaskDocumentUpload.vue
git commit -m "feat(frontend) : add TaskDocumentUpload drag & drop component"

Task 11: TaskDocumentList component

Files:

  • Create: frontend/components/task/TaskDocumentList.vue

  • Step 1: Create the document list component

<template>
    <div v-if="documents.length" class="mt-3">
        <p class="mb-2 text-sm font-medium text-neutral-700">
            {{ $t('taskDocuments.title') }} ({{ documents.length }})
        </p>
        <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
            <div
                v-for="doc in documents"
                :key="doc.id"
                class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
                @click="$emit('preview', doc)"
            >
                <!-- Thumbnail or icon -->
                <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
                    <img
                        v-if="isImage(doc.mimeType)"
                        :src="getDownloadUrl(doc.id)"
                        :alt="doc.originalName"
                        class="h-10 w-10 object-cover"
                    />
                    <Icon
                        v-else
                        :name="getIconForMime(doc.mimeType)"
                        class="h-6 w-6 text-neutral-400"
                    />
                </div>

                <!-- File info -->
                <div class="min-w-0 flex-1">
                    <p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
                    <p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
                </div>

                <!-- Delete button -->
                <button
                    v-if="isAdmin"
                    class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
                    @click.stop="$emit('delete', doc)"
                >
                    <Icon name="heroicons:x-mark" class="h-4 w-4" />
                </button>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'

defineProps<{
    documents: TaskDocument[]
    isAdmin: boolean
}>()

defineEmits<{
    preview: [doc: TaskDocument]
    delete: [doc: TaskDocument]
}>()

const { getDownloadUrl } = useTaskDocumentService()

function isImage(mimeType: string): boolean {
    return mimeType.startsWith('image/')
}

function getIconForMime(mimeType: string): string {
    if (mimeType === 'application/pdf') return 'heroicons:document-text'
    if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
    if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
    if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
    return 'heroicons:paper-clip'
}

function formatSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} o`
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
    return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script>
  • Step 2: Commit
git add frontend/components/task/TaskDocumentList.vue
git commit -m "feat(frontend) : add TaskDocumentList with thumbnails and icons"

Task 12: TaskDocumentPreview modal

Files:

  • Create: frontend/components/task/TaskDocumentPreview.vue

  • Step 1: Create the preview modal

<template>
    <Teleport to="body">
        <Transition name="fade" appear>
            <div
                v-if="document"
                class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
                @click.self="$emit('close')"
                @keydown.escape="$emit('close')"
                @keydown.left="$emit('prev')"
                @keydown.right="$emit('next')"
                tabindex="0"
                ref="overlayRef"
            >
                <!-- Close button -->
                <button
                    class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
                    @click="$emit('close')"
                >
                    <Icon name="heroicons:x-mark" class="h-6 w-6" />
                </button>

                <!-- Navigation arrows -->
                <button
                    v-if="hasPrev"
                    class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
                    @click="$emit('prev')"
                >
                    <Icon name="heroicons:chevron-left" class="h-6 w-6" />
                </button>
                <button
                    v-if="hasNext"
                    class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
                    @click="$emit('next')"
                >
                    <Icon name="heroicons:chevron-right" class="h-6 w-6" />
                </button>

                <!-- Content -->
                <div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
                    <!-- Image preview -->
                    <img
                        v-if="isImage"
                        :src="downloadUrl"
                        :alt="document.originalName"
                        class="max-h-[85vh] max-w-[90vw] object-contain"
                    />

                    <!-- PDF preview -->
                    <iframe
                        v-else-if="isPdf"
                        :src="downloadUrl"
                        class="h-[85vh] w-[80vw] rounded-lg bg-white"
                    />

                    <!-- Generic file -->
                    <div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
                        <Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
                        <p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
                        <p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
                        <a
                            :href="downloadUrl"
                            download
                            class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
                        >
                            {{ $t('taskDocuments.download') }}
                        </a>
                    </div>

                    <!-- File name footer -->
                    <p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'

const props = defineProps<{
    document: TaskDocument | null
    hasPrev: boolean
    hasNext: boolean
}>()

defineEmits<{
    close: []
    prev: []
    next: []
}>()

const overlayRef = ref<HTMLElement | null>(null)

const { getDownloadUrl } = useTaskDocumentService()

const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')

function formatSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} o`
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
    return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}

// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
    if (doc) {
        nextTick(() => overlayRef.value?.focus())
    }
})
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
    transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
    opacity: 0;
}
</style>
  • Step 2: Commit
git add frontend/components/task/TaskDocumentPreview.vue
git commit -m "feat(frontend) : add TaskDocumentPreview fullscreen modal"

Chunk 3: Frontend — Integration into TaskModal

Task 13: Integrate documents into TaskModal

Files:

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

  • Step 1: Add document state and methods to script

In <script setup> of TaskModal.vue, add:

import type { TaskDocument } from '~/services/dto/task-document'

const { remove: removeDocument } = useTaskDocumentService()

Add reactive state:

const previewDoc = ref<TaskDocument | null>(null)

Add computed for documents from task:

const documents = computed(() => props.task?.documents ?? [])

Add helper for admin check:

const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)

Add methods:

const previewIndex = computed(() => {
    if (!previewDoc.value) return -1
    return documents.value.findIndex(d => d.id === previewDoc.value!.id)
})

function openPreview(doc: TaskDocument) {
    previewDoc.value = doc
}

function prevPreview() {
    if (previewIndex.value > 0) {
        previewDoc.value = documents.value[previewIndex.value - 1]
    }
}

function nextPreview() {
    if (previewIndex.value < documents.value.length - 1) {
        previewDoc.value = documents.value[previewIndex.value + 1]
    }
}

async function handleDeleteDocument(doc: TaskDocument) {
    if (!confirm(t('taskDocuments.confirmDeleteMessage'))) return
    await removeDocument(doc.id)
    emit('saved')
}

function handleDocumentUploaded() {
    emit('saved')
}
  • Step 2: Add components in template

In the template, after the Description <div class="mt-5">...</div> block (line ~122) and before <TaskGitSection:

<!-- Documents -->
<TaskDocumentUpload
    v-if="isEditing && task && isAdmin"
    :task-id="task.id"
    @uploaded="handleDocumentUploaded"
/>
<TaskDocumentList
    v-if="isEditing && task"
    :documents="documents"
    :is-admin="isAdmin"
    @preview="openPreview"
    @delete="handleDeleteDocument"
/>

<!-- Document preview modal -->
<TaskDocumentPreview
    :document="previewDoc"
    :has-prev="previewIndex > 0"
    :has-next="previewIndex < documents.length - 1"
    @close="previewDoc = null"
    @prev="prevPreview"
    @next="nextPreview"
/>
  • Step 3: Verify the modal renders correctly

Run make dev-nuxt and open a task modal. Check:

  • Upload zone appears under description (only for admin)

  • Existing documents show in list

  • Clicking a document opens preview modal

  • Navigation arrows work

  • Delete button appears for admin

  • Step 4: Commit

git add frontend/components/task/TaskModal.vue
git commit -m "feat(frontend) : integrate documents into TaskModal"

Task 14: Final verification

  • Step 1: Full flow test
  1. Log in as admin (admin/admin)
  2. Open a task modal
  3. Drag a file onto the upload zone → document appears in list
  4. Click the document → preview modal opens
  5. Upload a PDF → preview shows embedded PDF
  6. Upload a non-image/non-PDF → preview shows download button
  7. Navigate between documents with arrows
  8. Close preview with Escape
  9. Delete a document → confirm → document removed
  10. Delete the task → verify files are cleaned up from disk
  • Step 2: Commit all remaining changes (if any)
git add -A
git commit -m "feat : task documents upload and preview"