diff --git a/config/sidebar.php b/config/sidebar.php
index 2f1e15f..945b5f9 100644
--- a/config/sidebar.php
+++ b/config/sidebar.php
@@ -25,6 +25,7 @@ return [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
+ ['label' => 'sidebar.general.portal', 'to' => '/portal', 'icon' => 'mdi:account-box-outline', 'module' => 'client-portal'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
diff --git a/frontend/app/middleware/auth.global.ts b/frontend/app/middleware/auth.global.ts
index 4c98f1e..0fec47c 100644
--- a/frontend/app/middleware/auth.global.ts
+++ b/frontend/app/middleware/auth.global.ts
@@ -14,6 +14,22 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/')
}
+ // Cloisonnement portail client : un utilisateur ROLE_CLIENT "pur" (a ROLE_CLIENT
+ // mais PAS ROLE_USER) n'a accès qu'aux pages /portal. Toute autre route interne
+ // est redirigée vers /portal. Les ROLE_ADMIN / ROLE_USER ne sont pas concernés
+ // (ils peuvent aussi visiter /portal pour prévisualiser).
+ if (auth.isAuthenticated && !isLogin) {
+ const roles = auth.user?.roles ?? []
+ const isPureClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_USER')
+
+ if (isPureClient) {
+ const isPortalRoute = to.path === '/portal' || to.path.startsWith('/portal/')
+ if (!isPortalRoute) {
+ return navigateTo('/portal')
+ }
+ }
+ }
+
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
diff --git a/frontend/app/middleware/portal.ts b/frontend/app/middleware/portal.ts
new file mode 100644
index 0000000..4210bf2
--- /dev/null
+++ b/frontend/app/middleware/portal.ts
@@ -0,0 +1,12 @@
+/**
+ * Named middleware for portal pages (`/portal/**`).
+ * Ensures the user is authenticated. Access is open to every authenticated user
+ * (ROLE_CLIENT see their portal, ROLE_ADMIN/ROLE_USER may preview it).
+ */
+export default defineNuxtRouteMiddleware(() => {
+ const auth = useAuthStore()
+
+ if (!auth.isAuthenticated) {
+ return navigateTo('/login')
+ }
+})
diff --git a/frontend/components/admin/AdminClientTicketTab.vue b/frontend/components/admin/AdminClientTicketTab.vue
new file mode 100644
index 0000000..cdb744d
--- /dev/null
+++ b/frontend/components/admin/AdminClientTicketTab.vue
@@ -0,0 +1,256 @@
+
+
+
+
{{ $t('clientTicket.adminTitle') }}
+
+
+
+
+
+
+
+
+ {{ formatTicketNumber((item as ClientTicket).number) }}
+
+
+
+
+
+
+
+
+
+ {{ projectName((item as ClientTicket).project) }}
+
+
+ {{ submitterName((item as ClientTicket).submittedBy) }}
+
+
+ {{ formatDate((item as ClientTicket).createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatTicketNumber(statusTicket.number) }} — {{ statusTicket.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/user/UserDrawer.vue b/frontend/components/user/UserDrawer.vue
index 964f818..beda546 100644
--- a/frontend/components/user/UserDrawer.vue
+++ b/frontend/components/user/UserDrawer.vue
@@ -48,6 +48,26 @@
+
+
+
{{ $t('users.clientAccount') }}
+
+
+
+
+
+
@@ -71,6 +91,8 @@
diff --git a/frontend/modules/client-portal/components/ClientTicketFormModal.vue b/frontend/modules/client-portal/components/ClientTicketFormModal.vue
new file mode 100644
index 0000000..4d5d496
--- /dev/null
+++ b/frontend/modules/client-portal/components/ClientTicketFormModal.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/client-portal/components/ClientTicketStatusBadge.vue b/frontend/modules/client-portal/components/ClientTicketStatusBadge.vue
new file mode 100644
index 0000000..f7d35b9
--- /dev/null
+++ b/frontend/modules/client-portal/components/ClientTicketStatusBadge.vue
@@ -0,0 +1,25 @@
+
+
+ {{ $t(`clientTicket.status.${status}`) }}
+
+
+
+
diff --git a/frontend/modules/client-portal/components/ClientTicketTypeBadge.vue b/frontend/modules/client-portal/components/ClientTicketTypeBadge.vue
new file mode 100644
index 0000000..ed9d76c
--- /dev/null
+++ b/frontend/modules/client-portal/components/ClientTicketTypeBadge.vue
@@ -0,0 +1,25 @@
+
+
+
+ {{ $t(`clientTicket.type.${type}`) }}
+
+
+
+
diff --git a/frontend/modules/client-portal/nuxt.config.ts b/frontend/modules/client-portal/nuxt.config.ts
new file mode 100644
index 0000000..268da7f
--- /dev/null
+++ b/frontend/modules/client-portal/nuxt.config.ts
@@ -0,0 +1 @@
+export default defineNuxtConfig({})
diff --git a/frontend/modules/client-portal/pages/portal.vue b/frontend/modules/client-portal/pages/portal.vue
new file mode 100644
index 0000000..b1d5de4
--- /dev/null
+++ b/frontend/modules/client-portal/pages/portal.vue
@@ -0,0 +1,93 @@
+
+
+
+
{{ $t('portal.title') }}
+
{{ $t('portal.projects') }}
+
+
+
+
+
+
+
+ {{ $t('portal.noProjects') }}
+
+
+
+
+
+
+ {{ project.name }}
+
+
+
+
+ {{ openCount(project.id) }}
+ {{ $t('portal.openTickets') }}
+
+
+
+
+
+
+
diff --git a/frontend/modules/client-portal/pages/portal/projects/[id].vue b/frontend/modules/client-portal/pages/portal/projects/[id].vue
new file mode 100644
index 0000000..c3efba8
--- /dev/null
+++ b/frontend/modules/client-portal/pages/portal/projects/[id].vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
{{ projectName }}
+
+
+
+
+
+
+
+
+
+ {{ $t('portal.noTickets') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/client-portal/services/client-tickets.ts b/frontend/modules/client-portal/services/client-tickets.ts
new file mode 100644
index 0000000..2bbd652
--- /dev/null
+++ b/frontend/modules/client-portal/services/client-tickets.ts
@@ -0,0 +1,60 @@
+import type {
+ ClientTicket,
+ ClientTicketCreate,
+ ClientTicketStatusUpdate,
+} from './dto/client-ticket'
+import type { HydraCollection } from '~/utils/api'
+import { extractHydraMembers } from '~/utils/api'
+
+export type ClientTicketFilters = {
+ project?: number | string
+ status?: string
+ submittedBy?: number | string
+}
+
+export function useClientTicketService() {
+ const api = useApi()
+
+ async function getAll(params?: ClientTicketFilters): Promise
{
+ const query: Record = {}
+ if (params?.project !== undefined && params.project !== '') {
+ query.project = typeof params.project === 'number'
+ ? `/api/projects/${params.project}`
+ : params.project
+ }
+ if (params?.status) {
+ query.status = params.status
+ }
+ if (params?.submittedBy !== undefined && params.submittedBy !== '') {
+ query.submittedBy = typeof params.submittedBy === 'number'
+ ? `/api/users/${params.submittedBy}`
+ : params.submittedBy
+ }
+ const data = await api.get>('/client_tickets', query)
+ return extractHydraMembers(data)
+ }
+
+ async function getById(id: number): Promise {
+ return api.get(`/client_tickets/${id}`)
+ }
+
+ async function create(payload: ClientTicketCreate): Promise {
+ return api.post('/client_tickets', payload as Record, {
+ toastSuccessKey: 'clientTicket.created',
+ })
+ }
+
+ async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise {
+ return api.patch(`/client_tickets/${id}`, payload as Record, {
+ toastSuccessKey: 'clientTicket.statusChanged',
+ })
+ }
+
+ async function remove(id: number): Promise {
+ await api.delete(`/client_tickets/${id}`, {}, {
+ toastSuccessKey: 'clientTicket.deleted',
+ })
+ }
+
+ return { getAll, getById, create, updateStatus, remove }
+}
diff --git a/frontend/modules/client-portal/services/dto/client-ticket.ts b/frontend/modules/client-portal/services/dto/client-ticket.ts
new file mode 100644
index 0000000..25f90e0
--- /dev/null
+++ b/frontend/modules/client-portal/services/dto/client-ticket.ts
@@ -0,0 +1,34 @@
+import type { TaskDocument } from '~/modules/project-management/services/dto/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 // IRI
+ submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
+ createdAt: string
+ updatedAt: string
+ documents?: TaskDocument[]
+}
+
+export type ClientTicketCreate = {
+ type: ClientTicketType
+ title: string
+ description: string
+ url?: string | null
+ project: string // IRI
+}
+
+export type ClientTicketStatusUpdate = {
+ status: ClientTicketStatus
+ statusComment?: string | null
+}
diff --git a/frontend/modules/client-portal/utils/ticket.ts b/frontend/modules/client-portal/utils/ticket.ts
new file mode 100644
index 0000000..04bc09c
--- /dev/null
+++ b/frontend/modules/client-portal/utils/ticket.ts
@@ -0,0 +1,18 @@
+/**
+ * Format a client-ticket number for display, e.g. `CT-001`.
+ */
+export function formatTicketNumber(value: number): string {
+ return `CT-${String(value).padStart(3, '0')}`
+}
+
+/**
+ * Extract the numeric id from an API Platform IRI (e.g. `/api/projects/5` → 5).
+ * Returns null when the IRI cannot be parsed.
+ */
+export function iriToId(iri: string | null | undefined): number | null {
+ if (!iri) {
+ return null
+ }
+ const match = iri.match(/(\d+)$/)
+ return match ? Number(match[1]) : null
+}
diff --git a/frontend/modules/project-management/components/TaskCard.vue b/frontend/modules/project-management/components/TaskCard.vue
index 777087c..4c8a970 100644
--- a/frontend/modules/project-management/components/TaskCard.vue
+++ b/frontend/modules/project-management/components/TaskCard.vue
@@ -20,6 +20,12 @@
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
+
{{ task.title }}
@@ -103,6 +109,7 @@