1303 lines
36 KiB
Markdown
1303 lines
36 KiB
Markdown
# 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`:
|
|
|
|
```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;`:
|
|
|
|
```nginx
|
|
client_max_body_size 55m;
|
|
```
|
|
|
|
- [ ] **Step 3: Restart containers to apply config**
|
|
|
|
```bash
|
|
docker restart php-lesstime-fpm nginx-lesstime
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```yaml
|
|
- uploads_data:/var/www/html/var/uploads
|
|
```
|
|
|
|
Under top-level `volumes`, add:
|
|
|
|
```yaml
|
|
uploads_data:
|
|
```
|
|
|
|
- [ ] **Step 2: Restart containers**
|
|
|
|
```bash
|
|
docker compose down && docker compose up -d
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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`:
|
|
|
|
```php
|
|
/** @var Collection<int, TaskDocument> */
|
|
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
|
|
#[Groups(['task:read'])]
|
|
private Collection $documents;
|
|
```
|
|
|
|
3. In constructor, add: `$this->documents = new ArrayCollection();`
|
|
|
|
4. Add getter:
|
|
|
|
```php
|
|
/** @return Collection<int, TaskDocument> */
|
|
public function getDocuments(): Collection
|
|
{
|
|
return $this->documents;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
mkdir -p var/uploads/documents
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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:
|
|
|
|
```yaml
|
|
App\State\TaskDocumentProcessor:
|
|
arguments:
|
|
$uploadDir: '%task_document_upload_dir%'
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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:
|
|
|
|
```yaml
|
|
App\Controller\TaskDocumentDownloadController:
|
|
arguments:
|
|
$uploadDir: '%task_document_upload_dir%'
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
|
```
|
|
|
|
- [ ] **Step 2: Test upload via curl**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
import type { TaskDocument } from './task-document'
|
|
```
|
|
|
|
Add to `Task` type:
|
|
|
|
```typescript
|
|
documents: TaskDocument[]
|
|
```
|
|
|
|
- [ ] **Step 3: Create task-documents service**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
import type { TaskDocument } from '~/services/dto/task-document'
|
|
|
|
const { remove: removeDocument } = useTaskDocumentService()
|
|
```
|
|
|
|
Add reactive state:
|
|
|
|
```typescript
|
|
const previewDoc = ref<TaskDocument | null>(null)
|
|
```
|
|
|
|
Add computed for documents from task:
|
|
|
|
```typescript
|
|
const documents = computed(() => props.task?.documents ?? [])
|
|
```
|
|
|
|
Add helper for admin check:
|
|
|
|
```typescript
|
|
const authStore = useAuthStore()
|
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
|
```
|
|
|
|
Add methods:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```html
|
|
<!-- 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**
|
|
|
|
```bash
|
|
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)**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat : task documents upload and preview"
|
|
```
|