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— AddclientTicketfield to theTasktype. After thedocuments: 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— AddclientandallowedProjectsfields for client users. This must happen before portal pages are built becauseauth.user.allowedProjectsneeds 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 theportallayout. Note: For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have noallowedProjects:
<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 theTaskModalpattern 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 optionalclientTicketIdprop as an alternative totaskId. 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— AdduploadForTicketandgetByTicketmethods. After the existinguploadfunction (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 theTaskDocumentListblock (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 smallheroicons:user-circleicon next to the task code iftask.clientTicketis set. In the template, after the<span>showingtask.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 sameheroicons:user-circleicon 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 hasclientTicketset. 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 thetabsarray (line 39), add a new entry after thebookstacktab:
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/portalinstead of/. The actual login page usesrouter.push, notnavigateTo.
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 thetypeBadgeClass,statusBadgeClass, andformatDatefunctions that are duplicated inClientTicketDetailModal.vue,portal/projects/[id]/index.vue, andAdminClientTicketTab.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 localtypeBadgeClass,statusBadgeClass,formatDatefunctions and addconst { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()frontend/pages/portal/projects/[id]/index.vue— Same replacementfrontend/components/admin/AdminClientTicketTab.vue— Remove localtypeBadgeClass,statusBadgeClass,formatDatefunctions and addconst { 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)