144a8a4685
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.
94 lines
3.7 KiB
Vue
94 lines
3.7 KiB
Vue
<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>
|