feat(client-portal) : portal front + client account admin (phases 1-2 front)

LST-69 (3.2) front. Client portal UI on the phase-1 backend.

- New frontend/modules/client-portal/ layer: /portal (project cards from the
  client's allowedProjects via /me), /portal/projects/[id] (tickets list,
  detail modal, create modal with document upload), client-tickets service +
  DTO, CT-XXX formatting.
- Front tenancy: auth.global.ts redirects a pure ROLE_CLIENT to /portal and
  blocks internal routes; portal pages open to any authenticated user.
- Admin: UserDrawer manages client accounts (ROLE_CLIENT + client +
  allowedProjects); new "Tickets client" admin tab (list, filters, status
  change with required comment on reject, detail modal).
- Kanban/my-tasks: client-ticket icon + tooltip when task.clientTicket is set
  (data via task:read, no extra call). TaskDocument upload generalized with a
  clientTicketId prop. getContent uses native fetch (text response).
- i18n portal/clientTicket keys; sidebar /portal item (module client-portal).

nuxt build passes; /portal routes present, existing routes intact.
This commit is contained in:
Matthieu
2026-06-21 01:03:58 +02:00
parent a2bbc8311d
commit 144a8a4685
24 changed files with 1189 additions and 29 deletions
@@ -0,0 +1,93 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('portal.title') }}</h1>
<p class="mt-1 text-sm text-neutral-500">{{ $t('portal.projects') }}</p>
</div>
<div v-if="isLoading" class="flex justify-center py-16">
<Icon name="heroicons:arrow-path" class="h-7 w-7 animate-spin text-neutral-400" />
</div>
<div v-else-if="!projects.length" class="rounded-xl border border-dashed border-neutral-300 py-16 text-center text-neutral-400">
{{ $t('portal.noProjects') }}
</div>
<div v-else class="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="group flex flex-col gap-3 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm transition hover:border-primary-300 hover:shadow-md"
>
<div class="flex items-start justify-between gap-2">
<h2 class="text-lg font-bold text-neutral-900 group-hover:text-primary-500">
{{ project.name }}
</h2>
<Icon name="mdi:folder-outline" class="h-6 w-6 text-neutral-300" />
</div>
<div class="mt-auto flex items-center gap-1.5 text-sm text-neutral-500">
<span class="text-base font-bold text-primary-500">{{ openCount(project.id) }}</span>
{{ $t('portal.openTickets') }}
</div>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import type { AllowedProject } from '~/services/dto/user-data'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
import { useProjectService } from '~/modules/project-management/services/projects'
import { iriToId } from '~/modules/client-portal/utils/ticket'
definePageMeta({ middleware: ['portal'] })
useHead({ title: 'Portail client' })
const auth = useAuthStore()
const { getAll: getTickets } = useClientTicketService()
const { getAll: getProjects } = useProjectService()
const isLoading = ref(true)
const projects = ref<AllowedProject[]>([])
const openCounts = ref<Record<number, number>>({})
function openCount(projectId: number): number {
return openCounts.value[projectId] ?? 0
}
async function load() {
isLoading.value = true
try {
// Client users get their projects from `/me` (allowedProjects, embedded id + name).
// Internal users (admin/user) previewing the portal fall back to the full project list.
const allowed = auth.user?.allowedProjects ?? []
if (allowed.length) {
projects.value = allowed
} else if (auth.user?.roles?.some((r) => r === 'ROLE_ADMIN' || r === 'ROLE_USER')) {
const all = await getProjects({ archived: false })
projects.value = all.map((p) => ({ id: p.id, name: p.name, '@id': p['@id'] }))
} else {
projects.value = []
}
// Count open tickets (status new / in_progress) per project.
const tickets = await getTickets()
const counts: Record<number, number> = {}
for (const ticket of tickets) {
if (ticket.status !== 'new' && ticket.status !== 'in_progress') {
continue
}
const pid = iriToId(ticket.project)
if (pid !== null) {
counts[pid] = (counts[pid] ?? 0) + 1
}
}
openCounts.value = counts
} finally {
isLoading.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,133 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<NuxtLink
to="/portal"
class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-600"
>
<Icon name="mdi:arrow-left" class="h-5 w-5" />
</NuxtLink>
<h1 class="text-2xl font-bold text-neutral-900">{{ projectName }}</h1>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('portal.newTicket')"
@click="formOpen = true"
/>
</div>
<div v-if="isLoading" class="flex justify-center py-16">
<Icon name="heroicons:arrow-path" class="h-7 w-7 animate-spin text-neutral-400" />
</div>
<div v-else-if="!tickets.length" class="rounded-xl border border-dashed border-neutral-300 py-16 text-center text-neutral-400">
{{ $t('portal.noTickets') }}
</div>
<div v-else class="flex flex-col gap-2">
<button
v-for="ticket in tickets"
:key="ticket.id"
type="button"
class="flex flex-wrap items-center gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-primary-300 hover:shadow-md"
@click="openDetail(ticket)"
>
<span class="font-mono text-sm font-semibold text-primary-500">
{{ formatTicketNumber(ticket.number) }}
</span>
<ClientTicketTypeBadge :type="ticket.type" />
<span class="min-w-0 flex-1 truncate font-semibold text-neutral-900">{{ ticket.title }}</span>
<ClientTicketStatusBadge :status="ticket.status" />
<span class="text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</span>
</button>
</div>
<ClientTicketFormModal
v-model="formOpen"
:project-iri="projectIri"
@created="load"
/>
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
/>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/modules/client-portal/services/dto/client-ticket'
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
import { useProjectService } from '~/modules/project-management/services/projects'
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
definePageMeta({ middleware: ['portal'] })
const route = useRoute()
const auth = useAuthStore()
const { getAll, getById } = useClientTicketService()
const { getById: getProject } = useProjectService()
const projectId = computed(() => Number(route.params.id))
const projectIri = computed(() => `/api/projects/${projectId.value}`)
const isLoading = ref(true)
const tickets = ref<ClientTicket[]>([])
const projectName = ref('')
const formOpen = ref(false)
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)
useHead(() => ({ title: projectName.value || 'Portail client' }))
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
async function openDetail(ticket: ClientTicket) {
// Re-fetch to get embedded documents (collection items may omit them).
try {
selectedTicket.value = await getById(ticket.id)
} catch {
selectedTicket.value = ticket
}
detailOpen.value = true
}
function resolveProjectName() {
const allowed = auth.user?.allowedProjects ?? []
const match = allowed.find((p) => p.id === projectId.value)
if (match) {
projectName.value = match.name
}
}
async function load() {
isLoading.value = true
try {
resolveProjectName()
tickets.value = await getAll({ project: projectId.value })
// Internal users (admin/user) have no allowedProjects → fetch the name directly.
if (!projectName.value && auth.user?.roles?.some((r) => r === 'ROLE_ADMIN' || r === 'ROLE_USER')) {
try {
const project = await getProject(projectId.value)
projectName.value = project.name
} catch {
projectName.value = ''
}
}
} finally {
isLoading.value = false
}
}
onMounted(load)
</script>