Files
Lesstime/docs/superpowers/plans/2026-03-15-client-portal-phase2.md
2026-03-15 19:18:25 +01:00

69 KiB

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:
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:
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):
<template>
    <div class="h-screen overflow-hidden">
        <div class="flex h-full">
            <!-- Mobile sidebar overlay -->
            <Transition name="sidebar-overlay">
                <div
                    v-if="ui.sidebarOpen"
                    class="fixed inset-0 z-40 bg-black/50 lg:hidden"
                    @click="ui.closeMobileSidebar()"
                />
            </Transition>

            <aside
                class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
                :class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
            >
                <div class="flex items-center justify-between">
                    <img src="/malio.png" alt="Logo" class="w-auto" />
                    <button
                        class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
                        @click="ui.closeMobileSidebar()"
                    >
                        <Icon name="mdi:close" size="20" />
                    </button>
                </div>
                <nav class="flex-1 px-4 pb-6">
                    <SidebarLink
                        to="/portal"
                        icon="mdi:folder-outline"
                        label="Mes projets"
                        :collapsed="false"
                        class="border-t border-secondary-500 pt-6"
                        @click="ui.closeMobileSidebar()"
                    />
                </nav>

                <div class="flex flex-col gap-2 items-center p-4">
                    <p class="font-bold">v {{ version }}</p>
                </div>
            </aside>

            <div class="h-full flex-1 flex flex-col min-h-0">
                <AppTopNav :user="auth.user" />
                <main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
                    <div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
                    <slot />
                </main>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { useAppVersion } from '~/composables/useAppVersion'

const auth = useAuthStore()
const ui = useUiStore()
const route = useRoute()
const { version } = useAppVersion()

// Close mobile sidebar on route change
watch(() => route.path, () => {
    ui.closeMobileSidebar()
})
</script>

<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
    transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
    opacity: 0;
}
</style>
  • Commit:
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:
    "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:
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:
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:
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):
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<ClientTicket[]> {
        const query: Record<string, unknown> = {}
        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<HydraCollection<ClientTicket>>('/client_tickets', query)
        return extractHydraMembers(data)
    }

    async function getById(id: number): Promise<ClientTicket> {
        return api.get<ClientTicket>(`/client_tickets/${id}`)
    }

    async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
        return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
            toastSuccessKey: 'portal.ticketCreated',
        })
    }

    async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
        return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
            toastSuccessKey: 'clientTicket.statusUpdated',
        })
    }

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

    return { getAll, getById, create, updateStatus, remove }
}
  • Commit:
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:
    clientTicket: {
        id: number
        number: number
        type: string
        status: string
        title: string
    } | null

The full Task type should now include clientTicket after documents:

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:
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:
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:
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:
<template>
    <div>
        <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
            <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
        </div>

        <div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('common.loading') }}
        </div>

        <div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('portal.noProjects') }}
        </div>

        <div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            <NuxtLink
                v-for="project in projects"
                :key="project.id"
                :to="`/portal/projects/${project.id}`"
                class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
            >
                <h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
                <p class="mt-2 text-sm text-neutral-500">
                    {{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
                </p>
            </NuxtLink>
        </div>
    </div>
</template>

<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'

definePageMeta({
    layout: 'portal',
})

const { t } = useI18n()
useHead({ title: t('portal.title') })

const auth = useAuthStore()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()

const projects = ref<Project[]>([])
const tickets = ref<ClientTicket[]>([])
const isLoading = ref(true)

const ticketCountByProject = computed(() => {
    const counts: Record<number, number> = {}
    for (const ticket of tickets.value) {
        if (ticket.status === 'new' || ticket.status === 'in_progress') {
            // Extract project ID from IRI
            const match = ticket.project.match(/\/api\/projects\/(\d+)/)
            if (match) {
                const projectId = Number(match[1])
                counts[projectId] = (counts[projectId] ?? 0) + 1
            }
        }
    }
    return counts
})

async function loadData() {
    isLoading.value = true
    try {
        if (auth.user?.roles?.includes('ROLE_ADMIN')) {
            // Admin sees all projects
            const allProjects = await projectService.getAll({ archived: false })
            projects.value = allProjects
        } else {
            // Client sees allowed projects
            projects.value = auth.user?.allowedProjects ?? []
        }
        tickets.value = await clientTicketService.getAll()
    } finally {
        isLoading.value = false
    }
}

onMounted(() => {
    loadData()
})
</script>
  • Commit:
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:
<template>
    <div>
        <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
            <div class="flex items-center justify-between gap-3">
                <div class="min-w-0">
                    <NuxtLink
                        to="/portal"
                        class="text-sm text-neutral-400 hover:text-primary-500"
                    >
                        {{ $t('portal.backToProject') }}
                    </NuxtLink>
                    <h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
                </div>
                <NuxtLink
                    :to="`/portal/projects/${projectId}/new-ticket`"
                    class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
                >
                    <span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
                    <span class="sm:hidden">+ Ticket</span>
                </NuxtLink>
            </div>
        </div>

        <div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('common.loading') }}
        </div>

        <div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('clientTicket.noTickets') }}
        </div>

        <div v-else class="mt-4 space-y-3">
            <div
                v-for="ticket in tickets"
                :key="ticket.id"
                class="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm transition hover:shadow-md"
                @click="openDetail(ticket)"
            >
                <div class="min-w-0 flex-1">
                    <div class="flex items-center gap-2">
                        <span class="text-sm font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
                        <span
                            class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                            :class="typeBadgeClass(ticket.type)"
                        >
                            {{ $t(`clientTicket.type.${ticket.type}`) }}
                        </span>
                    </div>
                    <h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</h4>
                    <p class="mt-1 text-xs text-neutral-400">
                        {{ formatDate(ticket.createdAt) }}
                    </p>
                </div>
                <span
                    class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold"
                    :class="statusBadgeClass(ticket.status)"
                >
                    {{ $t(`clientTicket.status.${ticket.status}`) }}
                </span>
            </div>
        </div>

        <!-- Ticket detail modal -->
        <ClientTicketDetailModal
            v-model="detailOpen"
            :ticket="selectedTicket"
        />
    </div>
</template>

<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'

definePageMeta({
    layout: 'portal',
})

const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))

useHead({ title: t('portal.title') })

const clientTicketService = useClientTicketService()
const auth = useAuthStore()

const tickets = ref<ClientTicket[]>([])
const isLoading = ref(true)
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)

const projectName = computed(() => {
    const me = auth.user as any
    if (me?.allowedProjects) {
        const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
        return project?.name ?? ''
    }
    return ''
})

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',
    })
}

function openDetail(ticket: ClientTicket) {
    selectedTicket.value = ticket
    detailOpen.value = true
}

async function loadTickets() {
    isLoading.value = true
    try {
        tickets.value = await clientTicketService.getAll({ project: projectId.value })
    } finally {
        isLoading.value = false
    }
}

onMounted(() => {
    loadTickets()
})
</script>
  • Commit:
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:
<template>
    <Teleport v-if="isOpen" to="body">
        <Transition name="ticket-modal" appear>
            <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                <!-- Backdrop -->
                <div
                    class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                    @click="close"
                />

                <!-- Modal -->
                <div
                    class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
                    style="max-height: min(90vh, 900px)"
                >
                    <!-- Header -->
                    <div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
                        <div class="flex items-center justify-between">
                            <div class="flex items-center gap-3">
                                <span
                                    v-if="ticket"
                                    class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
                                >
                                    CT-{{ String(ticket.number).padStart(3, '0') }}
                                </span>
                                <h2 class="text-lg font-bold tracking-tight text-neutral-900">
                                    {{ $t('portal.ticketDetail') }}
                                </h2>
                            </div>
                            <button
                                type="button"
                                class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
                                @click="close"
                            >
                                <Icon name="mdi:close" size="20" />
                            </button>
                        </div>
                    </div>

                    <!-- Body -->
                    <div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
                        <!-- Title -->
                        <h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>

                        <!-- Badges -->
                        <div class="mt-3 flex items-center gap-2">
                            <span
                                class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                                :class="typeBadgeClass(ticket.type)"
                            >
                                {{ $t(`clientTicket.type.${ticket.type}`) }}
                            </span>
                            <span
                                class="rounded-full px-3 py-1 text-xs font-semibold"
                                :class="statusBadgeClass(ticket.status)"
                            >
                                {{ $t(`clientTicket.status.${ticket.status}`) }}
                            </span>
                        </div>

                        <!-- Description -->
                        <div class="mt-4">
                            <p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
                            <p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
                        </div>

                        <!-- URL (if bug) -->
                        <div v-if="ticket.url" class="mt-4">
                            <p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
                            <a
                                :href="ticket.url"
                                target="_blank"
                                class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
                            >
                                {{ ticket.url }}
                            </a>
                        </div>

                        <!-- Status comment -->
                        <div v-if="ticket.statusComment" class="mt-4">
                            <p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
                            <p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
                        </div>

                        <!-- Documents -->
                        <TaskDocumentList
                            v-if="ticket.documents && ticket.documents.length"
                            :documents="ticket.documents"
                            :is-admin="false"
                            @preview="openPreview"
                        />

                        <!-- Document preview -->
                        <TaskDocumentPreview
                            :document="previewDoc"
                            :has-prev="previewIndex > 0"
                            :has-next="previewIndex < (ticket.documents?.length ?? 0) - 1"
                            @close="previewDoc = null"
                            @prev="prevPreview"
                            @next="nextPreview"
                        />

                        <!-- Date -->
                        <p class="mt-6 text-xs text-neutral-400">
                            {{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
                        </p>
                    </div>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

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

const props = defineProps<{
    modelValue: boolean
    ticket: ClientTicket | null
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value: boolean): void
}>()

const isOpen = computed({
    get: () => props.modelValue,
    set: (v) => emit('update:modelValue', v),
})

function close() {
    isOpen.value = false
}

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',
    })
}

// Document preview
const previewDoc = ref<TaskDocument | null>(null)

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

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

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

function nextPreview() {
    if (props.ticket?.documents && previewIndex.value < props.ticket.documents.length - 1) {
        previewDoc.value = props.ticket.documents[previewIndex.value + 1]
    }
}
</script>

<style scoped>
.ticket-modal-enter-active,
.ticket-modal-leave-active {
    transition: opacity 0.2s ease;
}

.ticket-modal-enter-active > div:last-child,
.ticket-modal-leave-active > div:last-child {
    transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}

.ticket-modal-enter-from,
.ticket-modal-leave-to {
    opacity: 0;
}

.ticket-modal-enter-from > div:last-child {
    transform: scale(0.95) translateY(8px);
    opacity: 0;
}

.ticket-modal-leave-to > div:last-child {
    transform: scale(0.97);
    opacity: 0;
}
</style>
  • Commit:
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:
<template>
    <div>
        <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
            <NuxtLink
                :to="`/portal/projects/${projectId}`"
                class="text-sm text-neutral-400 hover:text-primary-500"
            >
                {{ $t('portal.backToProject') }}
            </NuxtLink>
            <h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
        </div>

        <form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
            <!-- Type -->
            <div>
                <label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
                <select
                    v-model="form.type"
                    class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
                >
                    <option value="bug">{{ $t('clientTicket.type.bug') }}</option>
                    <option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
                    <option value="other">{{ $t('clientTicket.type.other') }}</option>
                </select>
            </div>

            <!-- Title -->
            <div class="mt-4">
                <MalioInputText
                    v-model="form.title"
                    :label="$t('clientTicket.title')"
                    input-class="w-full"
                    :error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
                    @blur="touched.title = true"
                />
            </div>

            <!-- Description -->
            <div class="mt-4">
                <MalioInputTextArea
                    v-model="form.description"
                    :label="$t('clientTicket.description')"
                    :size="5"
                />
            </div>

            <!-- URL (only for bug type) -->
            <div v-if="form.type === 'bug'" class="mt-4">
                <MalioInputText
                    v-model="form.url"
                    :label="$t('clientTicket.url')"
                    input-class="w-full"
                />
            </div>

            <!-- Document upload (only after ticket is created) -->
            <div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
                <p class="text-sm text-neutral-500">
                    <Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
                    Les documents pourront être ajoutés après la soumission du ticket.
                </p>
            </div>

            <!-- Submit -->
            <div class="mt-6 flex items-center gap-3">
                <NuxtLink
                    :to="`/portal/projects/${projectId}`"
                    class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
                >
                    {{ $t('common.cancel') }}
                </NuxtLink>
                <button
                    type="submit"
                    class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
                    :disabled="isSubmitting"
                >
                    {{ $t('portal.submitTicket') }}
                </button>
            </div>
        </form>
    </div>
</template>

<script setup lang="ts">
import type { ClientTicketType } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'

definePageMeta({
    layout: 'portal',
})

const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))

useHead({ title: t('portal.newTicket') })

const clientTicketService = useClientTicketService()

const form = reactive({
    type: 'bug' as ClientTicketType | string,
    title: '',
    description: '',
    url: '',
})

const touched = reactive({
    title: false,
})

const isSubmitting = ref(false)

async function handleSubmit() {
    touched.title = true
    if (!form.title.trim()) return
    if (!form.description.trim()) return

    isSubmitting.value = true
    try {
        await clientTicketService.create({
            type: form.type as ClientTicketType,
            title: form.title.trim(),
            description: form.description.trim(),
            url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
            project: `/api/projects/${projectId.value}`,
        })
        await navigateTo(`/portal/projects/${projectId.value}`)
    } finally {
        isSubmitting.value = false
    }
}
</script>
  • Commit:
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 <script setup> section (lines 48-132) with:

Replace the props definition (lines 51-53):

const props = defineProps<{
    taskId: number
}>()

With:

const props = defineProps<{
    taskId?: number
    clientTicketId?: number
}>()

Replace the uploadFile call in processFiles (line 112):

            await uploadFile(props.taskId, file)

With:

            if (props.clientTicketId) {
                await uploadForTicket(props.clientTicketId, file)
            } else if (props.taskId) {
                await uploadFile(props.taskId, file)
            }

Replace the service destructuring (line 59):

const { upload: uploadFile } = useTaskDocumentService()

With:

const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
  • Commit:
git add frontend/components/task/TaskDocumentUpload.vue
git commit -m "feat(documents) : generalize TaskDocumentUpload to support client ticket uploads"

Task 13: Add uploadForTicket and getByTicket methods to task-documents service

  • Modify frontend/services/task-documents.ts — Add uploadForTicket and getByTicket methods. After the existing upload function (after line 28), add:
    async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
        const formData = new FormData()
        formData.append('file', file)
        formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)

        return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
            method: 'POST',
            body: formData,
            credentials: 'include',
        })
    }

    async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
        const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
            clientTicket: `/api/client_tickets/${clientTicketId}`,
        })
        return extractHydraMembers(data)
    }

Update the return statement (line 41) to include the new methods:

    return { getByTask, getByTicket, upload, uploadForTicket, remove, getDownloadUrl }
  • Commit:
git add frontend/services/task-documents.ts
git commit -m "feat(documents) : add uploadForTicket and getByTicket methods to service"

Task 14: Add document upload to portal ticket detail modal

  • Modify frontend/components/client-ticket/ClientTicketDetailModal.vue — Add document upload zone and refresh capability inside the modal. After the TaskDocumentList block (before the date paragraph), add the upload zone:

After the existing TaskDocumentList block, add:

                        <!-- Upload zone -->
                        <TaskDocumentUpload
                            v-if="ticket"
                            :client-ticket-id="ticket.id"
                            @uploaded="refreshDocuments"
                        />

Add document refresh logic and update template references. The full updated <script setup> block should be replaced with:

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

const props = defineProps<{
    modelValue: boolean
    ticket: ClientTicket | null
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value: boolean): void
}>()

const isOpen = computed({
    get: () => props.modelValue,
    set: (v) => emit('update:modelValue', v),
})

function close() {
    isOpen.value = false
}

const { getByTicket } = useTaskDocumentService()

const localDocuments = ref<TaskDocument[]>([])

watch(() => props.ticket?.documents, (docs) => {
    localDocuments.value = docs ? [...docs] : []
}, { immediate: true })

async function refreshDocuments() {
    if (!props.ticket) return
    localDocuments.value = await getByTicket(props.ticket.id)
}

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',
    })
}

// Document preview
const previewDoc = ref<TaskDocument | null>(null)

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

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

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

function nextPreview() {
    if (previewIndex.value < localDocuments.value.length - 1) {
        previewDoc.value = localDocuments.value[previewIndex.value + 1]
    }
}
</script>

Also update the template references. Replace:

                        <TaskDocumentList
                            v-if="ticket.documents && ticket.documents.length"
                            :documents="ticket.documents"
                            :is-admin="false"
                            @preview="openPreview"
                        />

With:

                        <TaskDocumentList
                            v-if="localDocuments.length"
                            :documents="localDocuments"
                            :is-admin="false"
                            @preview="openPreview"
                        />

And replace:

                        <TaskDocumentPreview
                            :document="previewDoc"
                            :has-prev="previewIndex > 0"
                            :has-next="previewIndex < (ticket.documents?.length ?? 0) - 1"
                            @close="previewDoc = null"
                            @prev="prevPreview"
                            @next="nextPreview"
                        />

With:

                        <TaskDocumentPreview
                            :document="previewDoc"
                            :has-prev="previewIndex > 0"
                            :has-next="previewIndex < localDocuments.length - 1"
                            @close="previewDoc = null"
                            @prev="prevPreview"
                            @next="nextPreview"
                        />
  • Commit:
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 <span> showing task.project.code (line 11), add the icon. Replace:
                <span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>

With:

                <div class="flex items-center gap-1">
                    <span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
                    <Icon
                        v-if="task.clientTicket"
                        name="heroicons:user-circle"
                        class="h-4 w-4 text-blue-400"
                        :title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
                    />
                </div>
  • Commit:
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:
                    <span
                        v-if="task.project && task.number"
                        class="text-sm font-medium text-primary-500"
                    >
                        {{ task.project.code }}-{{ task.number }}
                    </span>

With:

                    <div class="flex items-center gap-1.5">
                        <Icon
                            v-if="task.clientTicket"
                            name="heroicons:user-circle"
                            class="h-4 w-4 text-blue-400"
                            :title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
                        />
                        <span
                            v-if="task.project && task.number"
                            class="text-sm font-medium text-primary-500"
                        >
                            {{ task.project.code }}-{{ task.number }}
                        </span>
                    </div>
  • Commit:
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 <h2> tag (line 27), add a client ticket badge. After the closing </div> of the header flex container (line 29), add:
                        <!-- Client ticket link -->
                        <div
                            v-if="isEditing && task?.clientTicket"
                            class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
                        >
                            <Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
                            <span class="text-sm font-medium text-blue-700">
                                {{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
                            </span>
                            <span
                                class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
                                :class="ticketStatusClass(task.clientTicket.status)"
                            >
                                {{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
                            </span>
                        </div>

In the <script setup>, add the helper function after isAdmin:

function ticketStatusClass(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'
    }
}
  • Commit:
git add frontend/components/task/TaskModal.vue
git commit -m "feat(task-modal) : show linked client ticket info in task modal header"

Chunk 6: Admin Client Tickets Tab

Task 18: Create AdminClientTicketTab component

  • Create frontend/components/admin/AdminClientTicketTab.vue — Admin tab with ticket list, filters (project, status), status change modal, and ticket detail view:
<template>
    <div>
        <div class="flex items-center justify-between">
            <h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
        </div>

        <!-- Filters -->
        <div class="mt-4 flex flex-wrap gap-3">
            <MalioSelect
                v-model="filterProjectId"
                :options="projectOptions"
                label="Projet"
                :empty-option-label="$t('clientTicket.allProjects')"
                min-width="!w-40"
                text-field="text-sm"
                text-value="text-sm"
            />
            <div>
                <label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
                <select
                    v-model="filterStatus"
                    class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
                >
                    <option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
                    <option value="new">{{ $t('clientTicket.status.new') }}</option>
                    <option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
                    <option value="done">{{ $t('clientTicket.status.done') }}</option>
                    <option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
                </select>
            </div>
        </div>

        <!-- Ticket list -->
        <div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('common.loading') }}
        </div>

        <div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
            {{ $t('clientTicket.noTickets') }}
        </div>

        <div v-else class="mt-4 overflow-x-auto">
            <table class="w-full text-left text-sm">
                <thead>
                    <tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
                        <th class="px-3 py-3">#</th>
                        <th class="px-3 py-3">Type</th>
                        <th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
                        <th class="px-3 py-3">Statut</th>
                        <th class="px-3 py-3">Projet</th>
                        <th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
                        <th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
                        <th class="px-3 py-3">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr
                        v-for="ticket in filteredTickets"
                        :key="ticket.id"
                        class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
                        @click="openDetail(ticket)"
                    >
                        <td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
                        <td class="px-3 py-3">
                            <span
                                class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                                :class="typeBadgeClass(ticket.type)"
                            >
                                {{ $t(`clientTicket.type.${ticket.type}`) }}
                            </span>
                        </td>
                        <td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
                        <td class="px-3 py-3">
                            <span
                                class="rounded-full px-2 py-0.5 text-xs font-semibold"
                                :class="statusBadgeClass(ticket.status)"
                            >
                                {{ $t(`clientTicket.status.${ticket.status}`) }}
                            </span>
                        </td>
                        <td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
                        <td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
                        <td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
                        <td class="px-3 py-3">
                            <div class="flex items-center gap-2">
                                <button
                                    class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
                                    :title="$t('clientTicket.changeStatus')"
                                    @click.stop="openStatusChange(ticket)"
                                >
                                    <Icon name="mdi:swap-horizontal" size="18" />
                                </button>
                                <button
                                    class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
                                    @click.stop="openDeleteConfirm(ticket)"
                                >
                                    <Icon name="mdi:delete-outline" size="18" />
                                </button>
                            </div>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>

        <!-- Status change modal -->
        <Teleport v-if="statusModalOpen" to="body">
            <Transition name="status-modal" appear>
                <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                    <div
                        class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                        @click="statusModalOpen = false"
                    />
                    <div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
                        <h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
                        <p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
                            CT-{{ String(statusTarget.number).padStart(3, '0') }}  {{ statusTarget.title }}
                        </p>

                        <div class="mt-4">
                            <label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
                            <select
                                v-model="newStatus"
                                class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
                            >
                                <option :value="null" disabled></option>
                                <option
                                    v-for="s in availableStatusTransitions"
                                    :key="s.value"
                                    :value="s.value"
                                >
                                    {{ s.label }}
                                </option>
                            </select>
                        </div>

                        <div v-if="newStatus === 'rejected'" class="mt-4">
                            <MalioInputTextArea
                                v-model="statusComment"
                                :label="$t('clientTicket.statusComment')"
                                :size="3"
                            />
                            <p v-if="rejectionError" class="mt-1 text-xs text-red-500">
                                {{ $t('clientTicket.rejectionRequired') }}
                            </p>
                        </div>

                        <div class="mt-6 flex justify-end gap-3">
                            <button
                                class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
                                @click="statusModalOpen = false"
                            >
                                {{ $t('common.cancel') }}
                            </button>
                            <button
                                class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
                                :disabled="isUpdatingStatus"
                                @click="confirmStatusChange"
                            >
                                Confirmer
                            </button>
                        </div>
                    </div>
                </div>
            </Transition>
        </Teleport>

        <!-- Delete confirm modal -->
        <Teleport v-if="deleteModalOpen" to="body">
            <Transition name="status-modal" appear>
                <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                    <div
                        class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                        @click="deleteModalOpen = false"
                    />
                    <div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
                        <h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
                        <p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
                        <div class="mt-6 flex justify-end gap-3">
                            <button
                                class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
                                @click="deleteModalOpen = false"
                            >
                                {{ $t('common.cancel') }}
                            </button>
                            <button
                                class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
                                :disabled="isDeleting"
                                @click="confirmDelete"
                            >
                                Supprimer
                            </button>
                        </div>
                    </div>
                </div>
            </Transition>
        </Teleport>

        <!-- Ticket detail modal (read-only) -->
        <ClientTicketDetailModal
            v-model="detailOpen"
            :ticket="detailTicket"
        />
    </div>
</template>

<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'

const { t } = useI18n()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const userService = useUserService()

const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)

// Filters
const filterProjectId = ref<number | null>(null)
const filterStatus = ref<string | null>(null)

const projectOptions = computed(() =>
    projects.value.map(p => ({ label: p.name, value: p.id }))
)

const filteredTickets = computed(() => {
    let result = tickets.value
    if (filterProjectId.value) {
        result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
    }
    if (filterStatus.value) {
        result = result.filter(t => t.status === filterStatus.value)
    }
    return result
})

// Status change modal
const statusModalOpen = ref(false)
const statusTarget = ref<ClientTicket | null>(null)
const newStatus = ref<string | null>(null)
const statusComment = ref('')
const rejectionError = ref(false)
const isUpdatingStatus = ref(false)

// Delete modal
const deleteModalOpen = ref(false)
const deleteTarget = ref<ClientTicket | null>(null)
const isDeleting = ref(false)

// Detail modal
const detailOpen = ref(false)
const detailTicket = ref<ClientTicket | null>(null)

const availableStatusTransitions = computed(() => {
    if (!statusTarget.value) return []
    const current = statusTarget.value.status
    const allStatuses: { label: string; value: ClientTicketStatus }[] = [
        { label: t('clientTicket.status.new'), value: 'new' },
        { label: t('clientTicket.status.in_progress'), value: 'in_progress' },
        { label: t('clientTicket.status.done'), value: 'done' },
        { label: t('clientTicket.status.rejected'), value: 'rejected' },
    ]
    // Filter out forbidden transitions
    return allStatuses.filter(s => {
        if (s.value === current) return false
        if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
        return true
    })
})

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 getProjectName(iri: string): string {
    const match = iri.match(/\/api\/projects\/(\d+)/)
    if (!match) return ''
    const id = Number(match[1])
    return projects.value.find(p => p.id === id)?.name ?? ''
}

function getSubmitterName(iri: string | null): string {
    if (!iri) return '-'
    const match = iri.match(/\/api\/users\/(\d+)/)
    if (!match) return ''
    const id = Number(match[1])
    return users.value.find(u => u.id === id)?.username ?? ''
}

function formatDate(iso: string): string {
    return new Date(iso).toLocaleDateString('fr-FR', {
        day: 'numeric',
        month: 'short',
        year: 'numeric',
    })
}

function openDetail(ticket: ClientTicket) {
    detailTicket.value = ticket
    detailOpen.value = true
}

function openStatusChange(ticket: ClientTicket) {
    statusTarget.value = ticket
    newStatus.value = null
    statusComment.value = ''
    rejectionError.value = false
    statusModalOpen.value = true
}

function openDeleteConfirm(ticket: ClientTicket) {
    deleteTarget.value = ticket
    deleteModalOpen.value = true
}

async function confirmStatusChange() {
    if (!statusTarget.value || !newStatus.value) return

    if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
        rejectionError.value = true
        return
    }

    isUpdatingStatus.value = true
    try {
        await clientTicketService.updateStatus(statusTarget.value.id, {
            status: newStatus.value as ClientTicketStatus,
            statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
        })
        statusModalOpen.value = false
        await loadTickets()
    } finally {
        isUpdatingStatus.value = false
    }
}

async function confirmDelete() {
    if (!deleteTarget.value) return
    isDeleting.value = true
    try {
        await clientTicketService.remove(deleteTarget.value.id)
        deleteModalOpen.value = false
        await loadTickets()
    } finally {
        isDeleting.value = false
    }
}

async function loadTickets() {
    tickets.value = await clientTicketService.getAll()
}

async function loadData() {
    isLoading.value = true
    try {
        const [t, p, u] = await Promise.all([
            clientTicketService.getAll(),
            projectService.getAll(),
            userService.getAll(),
        ])
        tickets.value = t
        projects.value = p
        users.value = u
    } finally {
        isLoading.value = false
    }
}

onMounted(() => {
    loadData()
})
</script>

<style scoped>
.status-modal-enter-active,
.status-modal-leave-active {
    transition: opacity 0.2s ease;
}
.status-modal-enter-from,
.status-modal-leave-to {
    opacity: 0;
}
</style>
  • Commit:
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:

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:

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:

            <AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
  • Commit:
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):

        await router.push('/')

Replace with:

        const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
        await router.push(isClient ? '/portal' : '/')
  • Commit:
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:
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:

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:
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)