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:
@@ -0,0 +1,132 @@
|
||||
<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>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<AppModal
|
||||
:model-value="modelValue"
|
||||
width="lg"
|
||||
:title="$t('portal.newTicket')"
|
||||
@update:model-value="onModalUpdate"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<MalioSelect
|
||||
v-model="form.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('clientTicket.typeLabel')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
:label="$t('clientTicket.title')"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? $t('clientTicket.titleRequired') : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:rows="5"
|
||||
input-class="w-full"
|
||||
:error="touched.description && !form.description.trim() ? $t('clientTicket.descriptionRequired') : ''"
|
||||
@blur="touched.description = true"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="form.type === 'bug'"
|
||||
v-model="form.url"
|
||||
:label="$t('clientTicket.url')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Documents : uploadable only once the ticket exists -->
|
||||
<div v-if="createdTicketId">
|
||||
<p class="text-sm font-semibold text-neutral-700">{{ $t('taskDocuments.title') }}</p>
|
||||
<TaskDocumentUpload :client-ticket-id="createdTicketId" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="!createdTicketId"
|
||||
:label="$t('common.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="$t('common.close')"
|
||||
button-class="w-auto px-6"
|
||||
@click="finish"
|
||||
/>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClientTicketCreate,
|
||||
ClientTicketType,
|
||||
} from '~/modules/client-portal/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
projectIri: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { create } = useClientTicketService()
|
||||
|
||||
const typeOptions = computed(() => ([
|
||||
{ label: t('clientTicket.type.bug'), value: 'bug' },
|
||||
{ label: t('clientTicket.type.improvement'), value: 'improvement' },
|
||||
{ label: t('clientTicket.type.other'), value: 'other' },
|
||||
]))
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const createdTicketId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
type: 'bug' as ClientTicketType,
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
description: false,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.type = 'bug'
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.url = ''
|
||||
touched.title = false
|
||||
touched.description = false
|
||||
createdTicketId.value = null
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
function onModalUpdate(value: boolean) {
|
||||
emit('update:modelValue', value)
|
||||
if (!value && createdTicketId.value) {
|
||||
// A ticket was created before closing → refresh the list.
|
||||
emit('created')
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
emit('update:modelValue', false)
|
||||
emit('created')
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
touched.description = true
|
||||
if (!form.title.trim() || !form.description.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ClientTicketCreate = {
|
||||
type: form.type,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
||||
project: props.projectIri,
|
||||
}
|
||||
const ticket = await create(payload)
|
||||
createdTicketId.value = ticket.id
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="classes"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${status}`) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketStatus } from '~/modules/client-portal/services/dto/client-ticket'
|
||||
|
||||
const props = defineProps<{
|
||||
status: ClientTicketStatus
|
||||
}>()
|
||||
|
||||
const STATUS_CONFIG: Record<ClientTicketStatus, string> = {
|
||||
new: 'bg-sky-100 text-sky-700',
|
||||
in_progress: 'bg-amber-100 text-amber-700',
|
||||
done: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-neutral-200 text-neutral-600',
|
||||
}
|
||||
|
||||
const classes = computed(() => STATUS_CONFIG[props.status] ?? STATUS_CONFIG.new)
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="config.classes"
|
||||
>
|
||||
<Icon :name="config.icon" class="h-3 w-3" />
|
||||
{{ $t(`clientTicket.type.${type}`) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketType } from '~/modules/client-portal/services/dto/client-ticket'
|
||||
|
||||
const props = defineProps<{
|
||||
type: ClientTicketType
|
||||
}>()
|
||||
|
||||
const TYPE_CONFIG: Record<ClientTicketType, { icon: string, classes: string }> = {
|
||||
bug: { icon: 'mdi:bug-outline', classes: 'bg-red-100 text-red-700' },
|
||||
improvement: { icon: 'mdi:lightbulb-on-outline', classes: 'bg-blue-100 text-blue-700' },
|
||||
other: { icon: 'mdi:dots-horizontal-circle-outline', classes: 'bg-neutral-100 text-neutral-700' },
|
||||
}
|
||||
|
||||
const config = computed(() => TYPE_CONFIG[props.type] ?? TYPE_CONFIG.other)
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -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>
|
||||
@@ -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<ClientTicket[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
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<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: ClientTicketCreate): Promise<ClientTicket> {
|
||||
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.statusChanged',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, updateStatus, remove }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user