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.
133 lines
4.4 KiB
Vue
133 lines
4.4 KiB
Vue
<template>
|
|
<AppModal
|
|
:model-value="modelValue"
|
|
width="lg"
|
|
@update:model-value="$emit('update:modelValue', $event)"
|
|
>
|
|
<template #title>
|
|
<span v-if="ticket" class="flex items-center gap-2">
|
|
<span class="font-mono text-primary-500">{{ formatTicketNumber(ticket.number) }}</span>
|
|
<ClientTicketTypeBadge :type="ticket.type" />
|
|
</span>
|
|
<span v-else>{{ $t('portal.ticketDetail') }}</span>
|
|
</template>
|
|
|
|
<div v-if="ticket" class="flex flex-col gap-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<h3 class="text-lg font-bold text-neutral-900">{{ ticket.title }}</h3>
|
|
<ClientTicketStatusBadge :status="ticket.status" />
|
|
</div>
|
|
|
|
<div>
|
|
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
|
|
{{ $t('clientTicket.description') }}
|
|
</p>
|
|
<p class="whitespace-pre-wrap text-sm text-neutral-700">{{ ticket.description }}</p>
|
|
</div>
|
|
|
|
<div v-if="ticket.url">
|
|
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
|
|
{{ $t('clientTicket.url') }}
|
|
</p>
|
|
<a
|
|
:href="ticket.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="break-all text-sm text-blue-600 hover:underline"
|
|
>{{ ticket.url }}</a>
|
|
</div>
|
|
|
|
<div v-if="ticket.statusComment">
|
|
<p class="mb-1 text-xs font-semibold uppercase text-neutral-400">
|
|
{{ $t('clientTicket.statusComment') }}
|
|
</p>
|
|
<p class="whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-700">
|
|
{{ ticket.statusComment }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="text-xs text-neutral-400">
|
|
{{ $t('clientTicket.created') }} : {{ formatDate(ticket.createdAt) }}
|
|
</div>
|
|
|
|
<!-- Documents -->
|
|
<div class="border-t border-neutral-100 pt-2">
|
|
<div v-if="loadingDocs" class="flex justify-center py-4">
|
|
<Icon name="heroicons:arrow-path" class="h-5 w-5 animate-spin text-neutral-400" />
|
|
</div>
|
|
<TaskDocumentList
|
|
v-else
|
|
:documents="documents"
|
|
:is-admin="false"
|
|
@preview="previewDoc = $event"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<TaskDocumentPreview
|
|
:document="previewDoc"
|
|
:has-prev="false"
|
|
:has-next="false"
|
|
@close="previewDoc = null"
|
|
/>
|
|
</AppModal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket } from '~/modules/client-portal/services/dto/client-ticket'
|
|
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
|
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
|
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
ticket: ClientTicket | null
|
|
}>()
|
|
|
|
defineEmits<{
|
|
'update:modelValue': [value: boolean]
|
|
}>()
|
|
|
|
const { getByClientTicket } = useTaskDocumentService()
|
|
|
|
const documents = ref<TaskDocument[]>([])
|
|
const loadingDocs = ref(false)
|
|
const previewDoc = ref<TaskDocument | null>(null)
|
|
|
|
function formatDate(value: string): string {
|
|
return new Date(value).toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
async function loadDocuments(ticketId: number) {
|
|
loadingDocs.value = true
|
|
try {
|
|
documents.value = await getByClientTicket(ticketId)
|
|
} catch {
|
|
documents.value = []
|
|
} finally {
|
|
loadingDocs.value = false
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => [props.modelValue, props.ticket?.id] as const,
|
|
([open, id]) => {
|
|
previewDoc.value = null
|
|
if (open && id) {
|
|
// Prefer documents embedded in the ticket, fall back to a dedicated fetch.
|
|
if (props.ticket?.documents?.length) {
|
|
documents.value = props.ticket.documents
|
|
} else {
|
|
documents.value = []
|
|
loadDocuments(id)
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|