# Client Portal Phase 2 — Portal & UI > **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:** Build the client-facing portal pages (project list, ticket list, ticket creation with document upload), add client ticket indicators on internal kanban/my-tasks views, and create the admin "Tickets client" tab for managing all tickets. **Architecture:** Portal pages live under `/portal/` and use the existing default layout with a simplified sidebar for ROLE_CLIENT users. Auth middleware is extended to redirect ROLE_CLIENT to `/portal` and block internal pages. Client ticket data on internal task views flows through the `task:read` serialization group (no extra API call). Admin tab follows the existing tab pattern in `admin.vue`. **Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript, Tailwind CSS **Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` **Depends on:** Phase 1 (`docs/superpowers/plans/2026-03-15-client-portal-phase1.md`) --- ## Chunk 1: Auth Middleware & Portal Layout ### Task 1: Update auth middleware for ROLE_CLIENT routing - [ ] **Modify `frontend/middleware/auth.global.ts`** — Add ROLE_CLIENT redirect logic. After the existing login redirect (line 14), add portal routing. Replace the full file with: ```typescript export default defineNuxtRouteMiddleware(async (to) => { const auth = useAuthStore() const isLogin = to.path === '/login' if (!auth.checked) { await auth.ensureSession() } if (!isLogin && !auth.isAuthenticated) { return navigateTo('/login') } if (isLogin && auth.isAuthenticated) { const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false return navigateTo(isClient ? '/portal' : '/') } // ROLE_CLIENT: redirect to /portal, block internal pages if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { const isPortalRoute = to.path.startsWith('/portal') const isLoginRoute = to.path === '/login' if (!isPortalRoute && !isLoginRoute) { return navigateTo('/portal') } } }) ``` - [ ] **Commit:** ```bash git add frontend/middleware/auth.global.ts git commit -m "feat(auth) : redirect ROLE_CLIENT to /portal and block internal pages" ``` ### Task 2: Create portal layout - [ ] **Create `frontend/layouts/portal.vue`** — Simplified layout for client users with minimal sidebar (logo, portal link, logout): ```vue ``` - [ ] **Commit:** ```bash git add frontend/layouts/portal.vue git commit -m "feat(portal) : add portal layout with simplified sidebar for client users" ``` ### Task 3: Add i18n keys for portal and client tickets - [ ] **Modify `frontend/i18n/locales/fr.json`** — Add portal and clientTicket sections. After the `"bookstack"` block (before the closing `}`), add: ```json "portal": { "title": "Portail client", "projects": "Mes projets", "openTickets": "tickets ouverts", "noProjects": "Aucun projet disponible.", "newTicket": "Nouveau ticket", "ticketDetail": "Détail du ticket", "backToProject": "Retour au projet", "submitTicket": "Soumettre le ticket", "ticketCreated": "Ticket soumis avec succès." }, "clientTicket": { "type": { "bug": "Bug", "improvement": "Amélioration", "other": "Autre" }, "status": { "new": "Nouveau", "in_progress": "En cours", "done": "Terminé", "rejected": "Rejeté" }, "title": "Titre", "description": "Description", "url": "URL (page concernée)", "statusComment": "Commentaire de statut", "created": "Ticket créé", "statusChanged": "Statut mis à jour", "confirmDelete": "Supprimer ce ticket ?", "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", "linkedTooltip": "Lié au ticket client {number}", "rejectionRequired": "Un commentaire est requis pour rejeter un ticket", "noTickets": "Aucun ticket.", "allStatuses": "Tous les statuts", "allProjects": "Tous les projets", "submittedBy": "Soumis par", "createdAt": "Créé le", "deleted": "Ticket supprimé avec succès.", "statusUpdated": "Statut mis à jour avec succès.", "adminTab": "Tickets client", "selectType": "Type de ticket", "changeStatus": "Changer le statut" } ``` - [ ] **Commit:** ```bash git add frontend/i18n/locales/fr.json git commit -m "feat(i18n) : add portal and client ticket translation keys" ``` --- ## Chunk 2: DTOs & Services ### Task 4: Create ClientTicket DTO - [ ] **Create `frontend/services/dto/client-ticket.ts`** — TypeScript types for client tickets: ```typescript import type { TaskDocument } from './task-document' export type ClientTicketType = 'bug' | 'improvement' | 'other' export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' export type ClientTicket = { '@id'?: string id: number number: number type: ClientTicketType title: string description: string url: string | null status: ClientTicketStatus statusComment: string | null project: string submittedBy: string | null createdAt: string updatedAt: string documents?: TaskDocument[] } export type ClientTicketWrite = { type: ClientTicketType title: string description: string url?: string | null project: string } export type ClientTicketStatusUpdate = { status: ClientTicketStatus statusComment?: string | null } ``` - [ ] **Commit:** ```bash git add frontend/services/dto/client-ticket.ts git commit -m "feat(dto) : add ClientTicket TypeScript types" ``` ### Task 5: Create client-tickets service - [ ] **Create `frontend/services/client-tickets.ts`** — API service for client tickets following the existing service pattern (`useTaskService`): ```typescript import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket' import type { HydraCollection } from '~/utils/api' import { extractHydraMembers } from '~/utils/api' export function useClientTicketService() { const api = useApi() async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise { const query: Record = {} if (params?.project) query.project = `/api/projects/${params.project}` if (params?.status) query.status = params.status if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}` const data = await api.get>('/client_tickets', query) return extractHydraMembers(data) } async function getById(id: number): Promise { return api.get(`/client_tickets/${id}`) } async function create(payload: ClientTicketWrite): Promise { return api.post('/client_tickets', payload as Record, { toastSuccessKey: 'portal.ticketCreated', }) } async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise { return api.patch(`/client_tickets/${id}`, payload as Record, { toastSuccessKey: 'clientTicket.statusUpdated', }) } async function remove(id: number): Promise { await api.delete(`/client_tickets/${id}`, {}, { toastSuccessKey: 'clientTicket.deleted', }) } return { getAll, getById, create, updateStatus, remove } } ``` - [ ] **Commit:** ```bash git add frontend/services/client-tickets.ts git commit -m "feat(service) : add client-tickets API service" ``` ### Task 6: Extend Task DTO with clientTicket field - [ ] **Modify `frontend/services/dto/task.ts`** — Add `clientTicket` field to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add: ```typescript clientTicket: { id: number number: number type: string status: string title: string } | null ``` The full `Task` type should now include `clientTicket` after `documents`: ```typescript import type { TaskStatus } from './task-status' import type { TaskEffort } from './task-effort' import type { TaskPriority } from './task-priority' import type { TaskTag } from './task-tag' import type { TaskGroup } from './task-group' import type { UserData } from './user-data' import type { Project } from './project' import type { TaskDocument } from './task-document' export type Task = { id: number '@id'?: string number: number title: string description: string | null status: TaskStatus | null effort: TaskEffort | null priority: TaskPriority | null assignee: UserData | null group: TaskGroup | null project: Project | null tags: TaskTag[] documents: TaskDocument[] archived: boolean clientTicket: { id: number number: number type: string status: string title: string } | null } export type TaskWrite = { title: string description: string | null status: string | null effort: string | null priority: string | null assignee: string | null group: string | null project: string tags: string[] archived?: boolean } ``` - [ ] **Commit:** ```bash git add frontend/services/dto/task.ts git commit -m "feat(dto) : add clientTicket field to Task type" ``` ### Task 7: Update UserData DTO for allowedProjects - [ ] **Modify `frontend/services/dto/user-data.ts`** — Add `client` and `allowedProjects` fields for client users. This must happen before portal pages are built because `auth.user.allowedProjects` needs proper typing. Replace the full file with: ```typescript import type { Project } from './project' export type UserData = { id: number '@id'?: string username: string roles: string[] client?: { id: number; name: string } | null allowedProjects?: Project[] } export type UserWrite = { username: string password?: string roles: string[] client?: string | null allowedProjects?: string[] } ``` - [ ] **Commit:** ```bash git add frontend/services/dto/user-data.ts git commit -m "feat(dto) : add client and allowedProjects fields to UserData type" ``` --- ## Chunk 3: Portal Pages ### Task 8: Create portal project list page - [ ] **Create `frontend/pages/portal/index.vue`** — List of client's allowed projects with open ticket count. Uses the `portal` layout. **Note:** For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have no `allowedProjects`: ```vue ``` - [ ] **Commit:** ```bash git add frontend/pages/portal/index.vue git commit -m "feat(portal) : add portal project list page" ``` ### Task 9: Create portal ticket list page - [ ] **Create `frontend/pages/portal/projects/[id]/index.vue`** — List of tickets for a project with status badges and ticket detail modal: ```vue ``` - [ ] **Commit:** ```bash git add frontend/pages/portal/projects/[id]/index.vue git commit -m "feat(portal) : add ticket list page for a project" ``` ### Task 10: Create ClientTicketDetailModal component - [ ] **Create `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Read-only modal showing ticket details (title, description, url, status, statusComment, documents). Follows the `TaskModal` pattern for styling: ```vue ``` - [ ] **Commit:** ```bash git add frontend/components/client-ticket/ClientTicketDetailModal.vue git commit -m "feat(portal) : add client ticket detail modal component" ``` ### Task 11: Create new ticket form page - [ ] **Create `frontend/pages/portal/projects/[id]/new-ticket.vue`** — Ticket creation form with type select, title, description, url (if bug), and document upload: ```vue ``` - [ ] **Commit:** ```bash git add frontend/pages/portal/projects/[id]/new-ticket.vue git commit -m "feat(portal) : add new ticket creation form page" ``` --- ## Chunk 4: Document Upload on Tickets ### Task 12: Generalize TaskDocumentUpload with optional clientTicketId prop - [ ] **Modify `frontend/components/task/TaskDocumentUpload.vue`** — Add an optional `clientTicketId` prop as an alternative to `taskId`. Replace the ` ``` Also update the template references. Replace: ```vue ``` With: ```vue ``` And replace: ```vue ``` With: ```vue ``` - [ ] **Commit:** ```bash git add frontend/components/client-ticket/ClientTicketDetailModal.vue git commit -m "feat(portal) : add document upload to ticket detail modal" ``` --- ## Chunk 5: Client Ticket Icon on Internal Views ### Task 15: Add client ticket icon to TaskCard - [ ] **Modify `frontend/components/task/TaskCard.vue`** — Add a small `heroicons:user-circle` icon next to the task code if `task.clientTicket` is set. In the template, after the `` showing `task.project.code` (line 11), add the icon. Replace: ```vue {{ task.project.code }}{{ task.number }} ``` With: ```vue
{{ task.project.code }}{{ task.number }}
``` - [ ] **Commit:** ```bash git add frontend/components/task/TaskCard.vue git commit -m "feat(kanban) : show client ticket icon on task cards linked to a ticket" ``` ### Task 16: Add client ticket icon to my-tasks list view - [ ] **Modify `frontend/pages/my-tasks.vue`** — Add the same `heroicons:user-circle` icon in the list view. In the list view task row, after the task code span (around line 418), add the icon. Replace: ```vue {{ task.project.code }}-{{ task.number }} ``` With: ```vue
{{ task.project.code }}-{{ task.number }}
``` - [ ] **Commit:** ```bash git add frontend/pages/my-tasks.vue git commit -m "feat(my-tasks) : show client ticket icon on tasks linked to a ticket" ``` ### Task 17: Show client ticket info in TaskModal - [ ] **Modify `frontend/components/task/TaskModal.vue`** — Show client ticket link info when editing a task that has `clientTicket` set. In the template, after the header `

` tag (line 27), add a client ticket badge. After the closing `` of the header flex container (line 29), add: ```vue
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }} {{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
``` In the ` ``` - [ ] **Commit:** ```bash git add frontend/components/admin/AdminClientTicketTab.vue git commit -m "feat(admin) : add client tickets tab with list, filters, status change, and delete" ``` ### Task 19: Register the new tab in admin.vue - [ ] **Modify `frontend/pages/admin.vue`** — Add the "Tickets client" tab. In the `tabs` array (line 39), add a new entry after the `bookstack` tab: Replace: ```typescript const tabs = [ { key: 'clients', label: 'Clients' }, { key: 'statuses', label: 'Statuts' }, { key: 'efforts', label: 'Efforts' }, { key: 'priorities', label: 'Priorités' }, { key: 'tags', label: 'Tags' }, { key: 'users', label: 'Utilisateurs' }, { key: 'gitea', label: 'Gitea' }, { key: 'bookstack', label: 'BookStack' }, ] as const ``` With: ```typescript const tabs = [ { key: 'clients', label: 'Clients' }, { key: 'statuses', label: 'Statuts' }, { key: 'efforts', label: 'Efforts' }, { key: 'priorities', label: 'Priorités' }, { key: 'tags', label: 'Tags' }, { key: 'users', label: 'Utilisateurs' }, { key: 'client-tickets', label: 'Tickets client' }, { key: 'gitea', label: 'Gitea' }, { key: 'bookstack', label: 'BookStack' }, ] as const ``` In the template, after the `AdminBookStackTab` (line 31), add: ```vue ``` - [ ] **Commit:** ```bash git add frontend/pages/admin.vue git commit -m "feat(admin) : register client tickets tab in admin page" ``` --- ## Chunk 7: Final Touches ### Task 20: Update login redirect to portal for client users - [ ] **Modify `frontend/pages/login.vue`** — After successful login, redirect ROLE_CLIENT users to `/portal` instead of `/`. The actual login page uses `router.push`, not `navigateTo`. Find this line (around line 66): ```typescript await router.push('/') ``` Replace with: ```typescript const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false await router.push(isClient ? '/portal' : '/') ``` - [ ] **Commit:** ```bash git add frontend/pages/login.vue git commit -m "feat(auth) : redirect client users to /portal after login" ``` ### Task 21: Extract duplicated helpers to composable - [ ] **Create `frontend/composables/useClientTicketHelpers.ts`** — Extract the `typeBadgeClass`, `statusBadgeClass`, and `formatDate` functions that are duplicated in `ClientTicketDetailModal.vue`, `portal/projects/[id]/index.vue`, and `AdminClientTicketTab.vue`: ```typescript export function useClientTicketHelpers() { function typeBadgeClass(type: string): string { switch (type) { case 'bug': return 'bg-red-500' case 'improvement': return 'bg-blue-500' default: return 'bg-neutral-500' } } function statusBadgeClass(status: string): string { switch (status) { case 'new': return 'bg-blue-100 text-blue-700' case 'in_progress': return 'bg-yellow-100 text-yellow-700' case 'done': return 'bg-green-100 text-green-700' case 'rejected': return 'bg-red-100 text-red-700' default: return 'bg-neutral-100 text-neutral-700' } } function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', }) } return { typeBadgeClass, statusBadgeClass, formatDate } } ``` - [ ] **Update the 3 components** to import and use the composable instead of local functions: - `frontend/components/client-ticket/ClientTicketDetailModal.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` - `frontend/pages/portal/projects/[id]/index.vue` — Same replacement - `frontend/components/admin/AdminClientTicketTab.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` - [ ] **Commit:** ```bash git add frontend/composables/useClientTicketHelpers.ts frontend/components/client-ticket/ClientTicketDetailModal.vue frontend/pages/portal/projects/\[id\]/index.vue frontend/components/admin/AdminClientTicketTab.vue git commit -m "refactor(portal) : extract duplicated ticket helpers to useClientTicketHelpers composable" ``` ### Task 22: Final commit — verify all files - [ ] **Run a final check** — Verify all new files are properly created and existing files are updated: ```bash git status ``` Verify the following files exist: - `frontend/middleware/auth.global.ts` (modified) - `frontend/layouts/portal.vue` (new) - `frontend/i18n/locales/fr.json` (modified) - `frontend/services/dto/client-ticket.ts` (new) - `frontend/services/client-tickets.ts` (new) - `frontend/services/dto/task.ts` (modified) - `frontend/services/dto/user-data.ts` (modified) - `frontend/services/task-documents.ts` (modified) - `frontend/pages/portal/index.vue` (new) - `frontend/pages/portal/projects/[id]/index.vue` (new) - `frontend/pages/portal/projects/[id]/new-ticket.vue` (new) - `frontend/components/client-ticket/ClientTicketDetailModal.vue` (new) - `frontend/components/task/TaskDocumentUpload.vue` (modified) - `frontend/components/task/TaskCard.vue` (modified) - `frontend/components/task/TaskModal.vue` (modified) - `frontend/pages/my-tasks.vue` (modified) - `frontend/pages/admin.vue` (modified) - `frontend/components/admin/AdminClientTicketTab.vue` (new) - `frontend/pages/login.vue` (modified) - `frontend/composables/useClientTicketHelpers.ts` (new)