Files
Lesstime/frontend/modules/client-portal/components/ClientTicketFormModal.vue
T
Matthieu 144a8a4685 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.
2026-06-21 01:03:58 +02:00

159 lines
4.3 KiB
Vue

<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>